amaye15 commited on
Commit
ed41019
·
1 Parent(s): c0a29ce
Dockerfile CHANGED
@@ -1,24 +1,20 @@
1
  # Use an official Python runtime as a parent image
2
- FROM python:3.12-slim
3
 
4
- # Set the working directory in the container
5
  WORKDIR /code
6
 
7
- # Copy the requirements file into the container
8
  COPY ./requirements.txt /code/requirements.txt
9
 
10
- # Install any needed packages specified in requirements.txt
11
  RUN pip install --no-cache-dir --upgrade pip && \
12
  pip install --no-cache-dir -r requirements.txt
13
 
14
- # Copy the application code (Backend API and Streamlit app)
15
  COPY ./app /code/app
16
- COPY ./streamlit_app.py /code/streamlit_app.py # Add streamlit app file
 
 
17
 
18
- # Make port 7860 available (Streamlit default is 8501, but HF uses specified port)
19
  EXPOSE 7860
20
 
21
- # Command to run the Streamlit application
22
- # Use --server.port to match EXPOSE and HF config
23
- # Use --server.address to bind correctly inside container
24
- CMD ["streamlit", "run", "streamlit_app.py", "--server.port=7860", "--server.address=0.0.0.0"]
 
1
  # Use an official Python runtime as a parent image
2
+ FROM python:3.12-slim # Or 3.10/3.11 if preferred
3
 
 
4
  WORKDIR /code
5
 
 
6
  COPY ./requirements.txt /code/requirements.txt
7
 
 
8
  RUN pip install --no-cache-dir --upgrade pip && \
9
  pip install --no-cache-dir -r requirements.txt
10
 
11
+ # Copy application code
12
  COPY ./app /code/app
13
+ # Copy static files and templates
14
+ COPY ./static /code/static
15
+ COPY ./templates /code/templates
16
 
 
17
  EXPOSE 7860
18
 
19
+ # Command to run the FastAPI application
20
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
app/api.py CHANGED
@@ -1,97 +1,60 @@
 
1
  from fastapi import APIRouter, HTTPException, status, Depends, WebSocket, WebSocketDisconnect
2
- from fastapi.responses import JSONResponse
3
  import logging
4
 
5
  from . import schemas, crud, auth, models
6
  from .websocket import manager
 
7
  from .dependencies import get_required_current_user
8
 
9
  router = APIRouter()
10
  logger = logging.getLogger(__name__)
11
 
12
- @router.post("/register", status_code=status.HTTP_201_CREATED, response_model=models.User)
 
13
  async def register_user(user_in: schemas.UserCreate):
 
14
  existing_user = await crud.get_user_by_email(user_in.email)
15
  if existing_user:
16
- raise HTTPException(
17
- status_code=status.HTTP_400_BAD_REQUEST,
18
- detail="Email already registered",
19
- )
20
  hashed_password = auth.get_password_hash(user_in.password)
21
  user_id = await crud.create_user(user_in=user_in, hashed_password=hashed_password)
22
-
23
- # Send notification to other connected users
24
- notification_msg = schemas.Notification(
25
- email=user_in.email,
26
- message=f"New user registered: {user_in.email}"
27
- ).model_dump_json() # Use model_dump_json for Pydantic v2
28
-
29
- # We broadcast but conceptually exclude the sender.
30
- # Since the new user isn't connected via WebSocket *yet* during registration,
31
- # we don't have a sender_id from the WebSocket context here.
32
- # We can pass the new user_id to prevent potential self-notification if
33
- # the WebSocket connection happens very quickly and maps the ID.
34
  await manager.broadcast(notification_msg, sender_id=user_id)
35
-
36
- # Return the newly created user's public info
37
- # Fetch the user details to return them accurately
38
  created_user = await crud.get_user_by_id(user_id)
39
- if not created_user:
40
- # This case should ideally not happen if create_user is successful
41
- raise HTTPException(status_code=500, detail="Failed to retrieve created user")
42
-
43
- # Convert UserInDB to User model for response
44
  return models.User(id=created_user.id, email=created_user.email)
45
 
46
-
47
- @router.post("/login", response_model=schemas.Token)
48
  async def login_for_access_token(form_data: schemas.UserLogin):
 
49
  user = await crud.get_user_by_email(form_data.email)
50
  if not user or not auth.verify_password(form_data.password, user.hashed_password):
51
- raise HTTPException(
52
- status_code=status.HTTP_401_UNAUTHORIZED,
53
- detail="Incorrect email or password",
54
- headers={"WWW-Authenticate": "Bearer"},
55
- )
56
  access_token = auth.create_session_token(user_id=user.id)
57
  return {"access_token": access_token, "token_type": "bearer"}
58
 
 
59
  @router.get("/users/me", response_model=models.User)
60
  async def read_users_me(current_user: models.User = Depends(get_required_current_user)):
61
- # This endpoint now relies on the dependency correctly getting the user from the token
62
- # The token needs to be passed to get_required_current_user somehow.
63
- # In Gradio's case, we might call this function directly from Gradio's backend
64
- # passing the token from gr.State, rather than relying on HTTP Headers/Cookies.
65
- # Let's adjust the dependency call when we use it in main.py.
66
  return current_user
67
 
68
-
69
- # WebSocket endpoint (can be associated with the main API router or separate)
70
  @router.websocket("/ws/{user_id_token}")
71
  async def websocket_endpoint(websocket: WebSocket, user_id_token: str):
72
- """
73
- WebSocket endpoint. Connects user and listens for messages.
74
- The user_id_token is the signed session token from login.
75
- """
76
  user_id = await auth.get_user_id_from_token(user_id_token)
77
  if user_id is None:
78
  logger.warning(f"WebSocket connection rejected: Invalid token {user_id_token}")
79
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
80
- return
81
-
82
  await manager.connect(websocket, user_id)
83
  try:
84
- while True:
85
- # Keep connection alive, maybe handle incoming messages if needed later
86
- data = await websocket.receive_text()
87
- # For now, we just broadcast on registration, not handle client messages
88
- logger.debug(f"Received message from {user_id}: {data} (currently ignored)")
89
- # Example: await websocket.send_text(f"Message text was: {data}")
90
- except WebSocketDisconnect:
91
- manager.disconnect(websocket)
92
- logger.info(f"WebSocket disconnected for user {user_id}")
93
- except Exception as e:
94
- manager.disconnect(websocket)
95
- logger.error(f"WebSocket error for user {user_id}: {e}")
96
- # Optionally close with an error code
97
- # await websocket.close(code=status.WS_1011_INTERNAL_ERROR)
 
1
+ # app/api.py
2
  from fastapi import APIRouter, HTTPException, status, Depends, WebSocket, WebSocketDisconnect
3
+ # Remove JSONResponse if not explicitly needed
4
  import logging
5
 
6
  from . import schemas, crud, auth, models
7
  from .websocket import manager
8
+ # --- Use the new dependency ---
9
  from .dependencies import get_required_current_user
10
 
11
  router = APIRouter()
12
  logger = logging.getLogger(__name__)
13
 
14
+ # --- (register and login endpoints remain the same) ---
15
+ @router.post("/register", ...) # Keep as is
16
  async def register_user(user_in: schemas.UserCreate):
17
+ # ... same logic ...
18
  existing_user = await crud.get_user_by_email(user_in.email)
19
  if existing_user:
20
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
 
 
 
21
  hashed_password = auth.get_password_hash(user_in.password)
22
  user_id = await crud.create_user(user_in=user_in, hashed_password=hashed_password)
23
+ notification_msg = schemas.Notification(email=user_in.email, message=f"New user registered: {user_in.email}").model_dump_json()
 
 
 
 
 
 
 
 
 
 
 
24
  await manager.broadcast(notification_msg, sender_id=user_id)
 
 
 
25
  created_user = await crud.get_user_by_id(user_id)
26
+ if not created_user: raise HTTPException(status_code=500, detail="Failed to retrieve created user")
 
 
 
 
27
  return models.User(id=created_user.id, email=created_user.email)
28
 
29
+ @router.post("/login", ...) # Keep as is
 
30
  async def login_for_access_token(form_data: schemas.UserLogin):
31
+ # ... same logic ...
32
  user = await crud.get_user_by_email(form_data.email)
33
  if not user or not auth.verify_password(form_data.password, user.hashed_password):
34
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"})
 
 
 
 
35
  access_token = auth.create_session_token(user_id=user.id)
36
  return {"access_token": access_token, "token_type": "bearer"}
37
 
38
+ # --- UPDATE this endpoint ---
39
  @router.get("/users/me", response_model=models.User)
40
  async def read_users_me(current_user: models.User = Depends(get_required_current_user)):
41
+ """
42
+ Returns the current authenticated user's details based on the
43
+ Authorization: Bearer <token> header.
44
+ """
45
+ # The dependency now handles getting the user from the header token
46
  return current_user
47
 
48
+ # --- (websocket endpoint remains the same) ---
 
49
  @router.websocket("/ws/{user_id_token}")
50
  async def websocket_endpoint(websocket: WebSocket, user_id_token: str):
51
+ # ... same logic ...
 
 
 
52
  user_id = await auth.get_user_id_from_token(user_id_token)
53
  if user_id is None:
54
  logger.warning(f"WebSocket connection rejected: Invalid token {user_id_token}")
55
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION); return
 
 
56
  await manager.connect(websocket, user_id)
57
  try:
58
+ while True: data = await websocket.receive_text(); logger.debug(f"Received WS msg from {user_id}: {data}")
59
+ except WebSocketDisconnect: manager.disconnect(websocket); logger.info(f"WebSocket disconnected for user {user_id}")
60
+ except Exception as e: manager.disconnect(websocket); logger.error(f"WebSocket error for user {user_id}: {e}")
 
 
 
 
 
 
 
 
 
 
 
app/dependencies.py CHANGED
@@ -1,22 +1,38 @@
1
- from fastapi import HTTPException, status, Request # Request may not be needed if token passed directly
 
2
  from typing import Optional
3
- from . import auth
4
- from .models import User
5
 
6
- # This dependency assumes the token is passed somehow,
7
- # e.g., in headers (less likely from Gradio client code) or as an argument
8
- # We will adapt how the token is passed from Gradio later.
9
- async def get_optional_current_user(token: Optional[str] = None) -> Optional[User]:
10
- if token:
 
 
 
 
 
 
 
11
  user = await auth.get_current_user_from_token(token)
12
  return user
13
- return None
 
14
 
15
- async def get_required_current_user(token: Optional[str] = None) -> User:
16
- user = await get_optional_current_user(token)
 
 
 
 
17
  if user is None:
 
18
  raise HTTPException(
19
  status_code=status.HTTP_401_UNAUTHORIZED,
20
- detail="Not authenticated",
 
21
  )
22
- return user
 
 
 
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordBearer # Use FastAPI's built-in helper
3
  from typing import Optional
4
+ from . import auth, models
 
5
 
6
+ # Setup OAuth2 scheme pointing to the login *API* endpoint
7
+ # tokenUrl relative to the path where the app is mounted
8
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login")
9
+
10
+ async def get_optional_current_user(token: str = Depends(oauth2_scheme)) -> Optional[models.User]:
11
+ """
12
+ Dependency to get the current user from the token in the Authorization header.
13
+ Returns None if the token is invalid or not provided.
14
+ Handles potential exceptions during token decoding/validation gracefully for optional user.
15
+ """
16
+ try:
17
+ # OAuth2PasswordBearer already extracts the token from the header
18
  user = await auth.get_current_user_from_token(token)
19
  return user
20
+ except Exception: # Catch exceptions if the token is invalid but we don't want to fail hard
21
+ return None
22
 
23
+ async def get_required_current_user(token: str = Depends(oauth2_scheme)) -> models.User:
24
+ """
25
+ Dependency to get the current user, raising HTTP 401 if not authenticated.
26
+ """
27
+ # OAuth2PasswordBearer will raise a 401 if the header is missing/malformed
28
+ user = await auth.get_current_user_from_token(token)
29
  if user is None:
30
+ # This case covers valid token format but expired/invalid signature/user not found
31
  raise HTTPException(
32
  status_code=status.HTTP_401_UNAUTHORIZED,
33
+ detail="Could not validate credentials", # Keep detail generic
34
+ headers={"WWW-Authenticate": "Bearer"},
35
  )
36
+ return user
37
+
38
+ # Modify the /users/me endpoint in api.py to use the new dependency
app/main.py CHANGED
@@ -1,31 +1,41 @@
1
  # app/main.py
2
- import gradio as gr
3
- import httpx
4
- import websockets
 
 
5
  import asyncio
6
  import json
7
  import os
8
  import logging
9
  from contextlib import asynccontextmanager
10
 
11
- from fastapi import FastAPI, Depends # Import FastAPI itself
 
 
 
 
 
12
  from .database import connect_db, disconnect_db, database, metadata, users
13
  from .api import router as api_router
14
  from . import schemas, auth, dependencies
15
- from .websocket import manager # Import the connection manager instance
16
 
 
17
  from sqlalchemy.schema import CreateTable
18
  from sqlalchemy.dialects import sqlite
19
 
 
20
  logging.basicConfig(level=logging.INFO)
21
  logger = logging.getLogger(__name__)
22
 
23
- API_BASE_URL = "http://127.0.0.1:7860/api"
 
24
 
25
- # --- Lifespan (remains the same) ---
26
  @asynccontextmanager
27
  async def lifespan(app: FastAPI):
28
- # ... (same DB setup code) ...
29
  logger.info("Application startup: Connecting DB...")
30
  await connect_db()
31
  logger.info("Application startup: DB Connected. Checking/Creating tables...")
@@ -34,16 +44,16 @@ async def lifespan(app: FastAPI):
34
  check_query = "SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;"
35
  table_exists = await database.fetch_one(query=check_query, values={"table_name": users.name})
36
  if not table_exists:
37
- logger.info(f"Table '{users.name}' not found, attempting creation using async connection...")
38
  dialect = sqlite.dialect()
39
  create_table_stmt = str(CreateTable(users).compile(dialect=dialect))
40
  await database.execute(query=create_table_stmt)
41
- logger.info(f"Table '{users.name}' created successfully via async connection.")
42
  table_exists_after = await database.fetch_one(query=check_query, values={"table_name": users.name})
43
- if table_exists_after: logger.info(f"Table '{users.name}' verified after creation.")
44
- else: logger.error(f"Table '{users.name}' verification FAILED after creation attempt!")
45
  else:
46
- logger.info(f"Table '{users.name}' already exists (checked via async connection).")
47
  except Exception as db_setup_err:
48
  logger.exception(f"CRITICAL error during async DB table setup: {db_setup_err}")
49
  else:
@@ -55,222 +65,42 @@ async def lifespan(app: FastAPI):
55
  logger.info("Application shutdown: DB Disconnected.")
56
 
57
 
58
- # --- FastAPI App Setup (remains the same) ---
59
  app = FastAPI(lifespan=lifespan)
60
- app.include_router(api_router, prefix="/api")
61
-
62
- # --- Helper functions (make_api_request remains the same) ---
63
- async def make_api_request(method: str, endpoint: str, **kwargs):
64
- async with httpx.AsyncClient() as client:
65
- url = f"{API_BASE_URL}{endpoint}"
66
- try: response = await client.request(method, url, **kwargs); response.raise_for_status(); return response.json()
67
- except httpx.RequestError as e: logger.error(f"HTTP Request failed: {e.request.method} {e.request.url} - {e}"); return {"error": f"Network error: {e}"}
68
- except httpx.HTTPStatusError as e:
69
- logger.error(f"HTTP Status error: {e.response.status_code} - {e.response.text}")
70
- try: detail = e.response.json().get("detail", e.response.text)
71
- except json.JSONDecodeError: detail = e.response.text
72
- return {"error": f"API Error: {detail}"}
73
- except Exception as e: logger.error(f"Unexpected error during API call: {e}"); return {"error": f"Unexpected error: {str(e)}"}
74
 
75
- # --- WebSocket handling ---
76
- # <<< Pass state objects by reference >>>
77
- async def listen_to_websockets(token: str, notification_list_state: gr.State, notification_trigger_state: gr.State):
78
- """Connects to WS and updates state list and trigger when a message arrives."""
79
- ws_listener_id = f"WSListener-{os.getpid()}-{asyncio.current_task().get_name()}"
80
- logger.info(f"[{ws_listener_id}] Starting WebSocket listener task.")
81
 
82
- if not token:
83
- logger.warning(f"[{ws_listener_id}] No token provided. Task exiting.")
84
- return # Just exit, don't need to return state values
 
 
85
 
86
- ws_url_base = API_BASE_URL.replace("http", "ws")
87
- ws_url = f"{ws_url_base}/ws/{token}"
88
- logger.info(f"[{ws_listener_id}] Attempting to connect: {ws_url}")
89
 
 
 
 
 
90
  try:
91
- async with websockets.connect(ws_url, open_timeout=15.0) as websocket:
92
- logger.info(f"[{ws_listener_id}] WebSocket connected successfully.")
93
- while True:
94
- try:
95
- message_str = await asyncio.wait_for(websocket.recv(), timeout=300.0)
96
- logger.info(f"[{ws_listener_id}] Received: {message_str[:100]}...")
97
- try:
98
- message_data = json.loads(message_str)
99
- if message_data.get("type") == "new_user":
100
- notification = schemas.Notification(**message_data)
101
- logger.info(f"[{ws_listener_id}] Processing 'new_user': {notification.message}")
102
- # --- Modify state values directly ---
103
- current_list = notification_list_state.value.copy() # Operate on copy
104
- current_list.insert(0, notification.message)
105
- if len(current_list) > 10: current_list.pop()
106
- notification_list_state.value = current_list # Assign back modified copy
107
- notification_trigger_state.value += 1 # Increment trigger
108
- # --- Log the update ---
109
- logger.info(f"[{ws_listener_id}] States updated: list len={len(notification_list_state.value)}, trigger={notification_trigger_state.value}")
110
- else:
111
- logger.warning(f"[{ws_listener_id}] Unknown message type: {message_data.get('type')}")
112
- # ... (error handling for parsing) ...
113
- except json.JSONDecodeError: logger.error(f"[{ws_listener_id}] JSON Decode Error: {message_str}")
114
- except Exception as parse_err: logger.error(f"[{ws_listener_id}] Message Processing Error: {parse_err}")
115
- # ... (error handling for websocket recv/connection) ...
116
- except asyncio.TimeoutError: logger.debug(f"[{ws_listener_id}] WebSocket recv timed out."); continue
117
- except websockets.ConnectionClosedOK: logger.info(f"[{ws_listener_id}] WebSocket closed normally."); break
118
- except websockets.ConnectionClosedError as e: logger.error(f"[{ws_listener_id}] WebSocket closed with error: {e}"); break
119
- except Exception as e: logger.error(f"[{ws_listener_id}] Listener loop error: {e}"); await asyncio.sleep(1); break
120
- # ... (error handling for websocket connect) ...
121
- except asyncio.TimeoutError: logger.error(f"[{ws_listener_id}] WebSocket initial connection timed out.")
122
- except websockets.exceptions.InvalidURI: logger.error(f"[{ws_listener_id}] Invalid WebSocket URI.")
123
- except websockets.exceptions.WebSocketException as e: logger.error(f"[{ws_listener_id}] WebSocket connection failed: {e}")
124
- except Exception as e: logger.error(f"[{ws_listener_id}] Unexpected error in listener task: {e}")
125
-
126
- logger.info(f"[{ws_listener_id}] Listener task finished.")
127
- # No need to return state values when task ends
128
-
129
- # --- Gradio Interface ---
130
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
131
- # State variables
132
- auth_token = gr.State(None)
133
- user_info = gr.State(None)
134
- notification_list = gr.State([])
135
- websocket_task = gr.State(None)
136
- # Add trigger states
137
- notification_trigger = gr.State(0)
138
- last_polled_trigger = gr.State(0) # State to track last seen trigger
139
-
140
- # --- UI Components ---
141
- with gr.Tabs() as tabs:
142
- # --- Registration/Login Tabs (remain the same) ---
143
- with gr.TabItem("Register", id="register_tab"):
144
- gr.Markdown("## Create a new account")
145
- reg_email = gr.Textbox(label="Email", type="email")
146
- reg_password = gr.Textbox(label="Password (min 8 chars)", type="password")
147
- reg_confirm_password = gr.Textbox(label="Confirm Password", type="password")
148
- reg_button = gr.Button("Register")
149
- reg_status = gr.Textbox(label="Status", interactive=False)
150
- with gr.TabItem("Login", id="login_tab"):
151
- gr.Markdown("## Login to your account")
152
- login_email = gr.Textbox(label="Email", type="email")
153
- login_password = gr.Textbox(label="Password", type="password")
154
- login_button = gr.Button("Login")
155
- login_status = gr.Textbox(label="Status", interactive=False)
156
-
157
- # --- Welcome Tab ---
158
- with gr.TabItem("Welcome", id="welcome_tab", visible=False) as welcome_tab:
159
- gr.Markdown("## Welcome!", elem_id="welcome_header")
160
- welcome_message = gr.Markdown("", elem_id="welcome_message")
161
- logout_button = gr.Button("Logout")
162
- gr.Markdown("---")
163
- gr.Markdown("## Real-time Notifications")
164
- notification_display = gr.Textbox( # Visible display
165
- label="New User Alerts", lines=5, max_lines=10, interactive=False
166
- )
167
- # <<< Hidden component for polling >>>
168
- dummy_poller = gr.Number(label="poller", value=0, visible=False, every=1)
169
-
170
-
171
- # --- Event Handlers ---
172
-
173
- # Registration Logic (remains the same)
174
- async def handle_register(email, password, confirm_password):
175
- if not email or not password or not confirm_password: return gr.update(value="Please fill fields.")
176
- if password != confirm_password: return gr.update(value="Passwords mismatch.")
177
- if len(password) < 8: return gr.update(value="Password >= 8 chars.")
178
- payload = {"email": email, "password": password}
179
- result = await make_api_request("post", "/register", json=payload)
180
- if "error" in result: return gr.update(value=f"Register failed: {result['error']}")
181
- else: return gr.update(value=f"Register success: {result.get('email')}! Log in.")
182
- reg_button.click(handle_register, inputs=[reg_email, reg_password, reg_confirm_password], outputs=[reg_status])
183
-
184
- # Login Logic
185
- # <<< MODIFIED: Pass/Reset both trigger states >>>
186
- async def handle_login(email, password, current_task, current_trigger_val, current_last_poll_val):
187
- # Define failure outputs matching the outputs list
188
- fail_outputs = (gr.update(value="..."), None, None, None, gr.update(visible=False), None, current_task, current_trigger_val, current_last_poll_val)
189
-
190
- if not email or not password: return fail_outputs[:1] + (gr.update(value="Enter email/password"),) + fail_outputs[2:]
191
-
192
- payload = {"email": email, "password": password}
193
- result = await make_api_request("post", "/login", json=payload)
194
-
195
- if "error" in result: return (gr.update(value=f"Login failed: {result['error']}"),) + fail_outputs[1:]
196
- else:
197
- token = result.get("access_token")
198
- user_data = await dependencies.get_optional_current_user(token)
199
- if not user_data: return (gr.update(value="Login ok but user fetch failed."),) + fail_outputs[1:]
200
-
201
- if current_task and not current_task.done():
202
- current_task.cancel()
203
- try: await current_task
204
- except asyncio.CancelledError: logger.info("Previous WebSocket task cancelled.")
205
-
206
- # <<< Pass state objects to listener >>>
207
- new_task = asyncio.create_task(listen_to_websockets(token, notification_list, notification_trigger))
208
-
209
- welcome_msg = f"Welcome, {user_data.email}!"
210
- # Reset triggers on successful login
211
- return (
212
- gr.update(value="Login successful!"), token, user_data.model_dump(), # status, token, user_info
213
- gr.update(selected="welcome_tab"), gr.update(visible=True), gr.update(value=welcome_msg), # UI changes
214
- new_task, 0, 0 # websocket_task, notification_trigger, last_polled_trigger
215
- )
216
-
217
- # <<< MODIFIED: Add last_polled_trigger to inputs/outputs >>>
218
- login_button.click(
219
- handle_login,
220
- inputs=[login_email, login_password, websocket_task, notification_trigger, last_polled_trigger],
221
- outputs=[login_status, auth_token, user_info, tabs, welcome_tab, welcome_message, websocket_task, notification_trigger, last_polled_trigger]
222
- )
223
-
224
-
225
- # <<< Polling function >>>
226
- def poll_and_update(current_trigger_value, last_known_trigger, current_notif_list):
227
- """ Checks trigger state change and updates UI if needed. """
228
- if current_trigger_value != last_known_trigger:
229
- logger.info(f"Polling detected trigger change ({last_known_trigger} -> {current_trigger_value}). Updating UI.")
230
- new_value = "\n".join(current_notif_list)
231
- # Return new display value AND update last_known_trigger state value
232
- return gr.update(value=new_value), current_trigger_value
233
- else:
234
- # No change, return NoUpdate for display AND existing last_known_trigger value
235
- return gr.NoUpdate(), last_known_trigger
236
-
237
- # <<< Attach polling function to the dummy component's change event >>>
238
- dummy_poller.change(
239
- fn=poll_and_update,
240
- inputs=[notification_trigger, last_polled_trigger, notification_list],
241
- outputs=[notification_display, last_polled_trigger], # Update display & last polled state
242
- queue=False # Try immediate update
243
- )
244
-
245
-
246
- # Logout Logic
247
- # <<< MODIFIED: Reset both trigger states >>>
248
- async def handle_logout(current_task):
249
- if current_task and not current_task.done():
250
- current_task.cancel()
251
- try: await current_task
252
- except asyncio.CancelledError: logger.info("WebSocket task cancelled on logout.")
253
- # Reset all relevant states
254
- return ( None, None, [], None, # auth_token, user_info, notification_list, websocket_task
255
- gr.update(selected="login_tab"), gr.update(visible=False), # tabs, welcome_tab
256
- gr.update(value=""), gr.update(value=""), # welcome_message, login_status
257
- 0, 0 ) # notification_trigger, last_polled_trigger (reset)
258
 
259
- # <<< MODIFIED: Add last_polled_trigger to outputs >>>
260
- logout_button.click(
261
- handle_logout,
262
- inputs=[websocket_task],
263
- outputs=[
264
- auth_token, user_info, notification_list, websocket_task,
265
- tabs, welcome_tab, welcome_message, login_status,
266
- notification_trigger, last_polled_trigger # Add trigger states here
267
- ]
268
- )
269
 
270
- # Mount Gradio App (remains the same)
271
- app = gr.mount_gradio_app(app, demo, path="/")
272
 
273
- # Run Uvicorn (remains the same)
274
  if __name__ == "__main__":
275
  import uvicorn
 
 
276
  uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=True)
 
1
  # app/main.py
2
+ # Remove Gradio imports if any remain
3
+ # import gradio as gr <--- REMOVE
4
+
5
+ import httpx # Keep if needed, but not used in this version of main.py
6
+ import websockets # Keep if needed, but not used in this version of main.py
7
  import asyncio
8
  import json
9
  import os
10
  import logging
11
  from contextlib import asynccontextmanager
12
 
13
+ from fastapi import FastAPI, Depends, Request # Add Request
14
+ from fastapi.responses import HTMLResponse # Add HTMLResponse
15
+ from fastapi.staticfiles import StaticFiles # Add StaticFiles
16
+ from fastapi.templating import Jinja2Templates # Add Jinja2Templates (optional, but good practice)
17
+
18
+ # --- Import necessary items from database.py ---
19
  from .database import connect_db, disconnect_db, database, metadata, users
20
  from .api import router as api_router
21
  from . import schemas, auth, dependencies
22
+ from .websocket import manager # Keep
23
 
24
+ # --- Import SQLAlchemy helpers for DDL generation ---
25
  from sqlalchemy.schema import CreateTable
26
  from sqlalchemy.dialects import sqlite
27
 
28
+ # Configure logging
29
  logging.basicConfig(level=logging.INFO)
30
  logger = logging.getLogger(__name__)
31
 
32
+ # --- REMOVE API_BASE_URL if not needed elsewhere ---
33
+ # API_BASE_URL = "http://127.0.0.1:7860/api"
34
 
35
+ # --- Lifespan Event (remains the same) ---
36
  @asynccontextmanager
37
  async def lifespan(app: FastAPI):
38
+ # ... (same DB setup code as previous correct version) ...
39
  logger.info("Application startup: Connecting DB...")
40
  await connect_db()
41
  logger.info("Application startup: DB Connected. Checking/Creating tables...")
 
44
  check_query = "SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;"
45
  table_exists = await database.fetch_one(query=check_query, values={"table_name": users.name})
46
  if not table_exists:
47
+ logger.info(f"Table '{users.name}' not found, creating...")
48
  dialect = sqlite.dialect()
49
  create_table_stmt = str(CreateTable(users).compile(dialect=dialect))
50
  await database.execute(query=create_table_stmt)
51
+ logger.info(f"Table '{users.name}' created.")
52
  table_exists_after = await database.fetch_one(query=check_query, values={"table_name": users.name})
53
+ if table_exists_after: logger.info(f"Table '{users.name}' verified.")
54
+ else: logger.error(f"Table '{users.name}' verification FAILED!")
55
  else:
56
+ logger.info(f"Table '{users.name}' already exists.")
57
  except Exception as db_setup_err:
58
  logger.exception(f"CRITICAL error during async DB table setup: {db_setup_err}")
59
  else:
 
65
  logger.info("Application shutdown: DB Disconnected.")
66
 
67
 
68
+ # Create the main FastAPI app instance
69
  app = FastAPI(lifespan=lifespan)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
+ # Mount API routes FIRST
72
+ app.include_router(api_router, prefix="/api")
 
 
 
 
73
 
74
+ # --- Mount Static files ---
75
+ # Ensure the path exists relative to where you run uvicorn (or use absolute paths)
76
+ # Since main.py is in app/, static/ is one level up
77
+ # Adjust 'directory' path if needed based on your execution context
78
+ app.mount("/static", StaticFiles(directory="static"), name="static")
79
 
80
+ # --- Optional: Use Jinja2Templates for more flexibility ---
81
+ # templates = Jinja2Templates(directory="templates")
 
82
 
83
+ # --- Serve the main HTML page ---
84
+ @app.get("/", response_class=HTMLResponse)
85
+ async def read_root(request: Request):
86
+ # Simple way: Read the file directly
87
  try:
88
+ with open("templates/index.html", "r") as f:
89
+ html_content = f.read()
90
+ return HTMLResponse(content=html_content)
91
+ except FileNotFoundError:
92
+ logger.error("templates/index.html not found!")
93
+ return HTMLResponse(content="<html><body><h1>Error: Frontend not found</h1></body></html>", status_code=500)
94
+ # Jinja2 way (if using templates):
95
+ # return templates.TemplateResponse("index.html", {"request": request})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
 
 
 
 
 
 
 
 
 
 
97
 
98
+ # --- REMOVE Gradio mounting ---
99
+ # app = gr.mount_gradio_app(app, demo, path="/")
100
 
101
+ # --- Uvicorn run command (no changes needed here) ---
102
  if __name__ == "__main__":
103
  import uvicorn
104
+ # Note: If running from the project root directory (fastapi_gradio_auth/),
105
+ # the app path is "app.main:app"
106
  uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=True)
requirements.txt CHANGED
@@ -1,9 +1,8 @@
1
  fastapi==0.111.0
2
  uvicorn[standard]==0.29.0
3
- # gradio==4.29.0 # Removed
4
- streamlit==1.33.0 # Added (or latest)
5
- passlib[bcrypt]==1.7.4
6
  bcrypt==4.1.3
 
7
  python-dotenv==1.0.1
8
  databases[sqlite]==0.9.0
9
  sqlalchemy==2.0.29
@@ -11,5 +10,5 @@ pydantic==2.7.1
11
  python-multipart==0.0.9
12
  itsdangerous==2.1.2
13
  websockets>=11.0.3,<13.0
14
- httpx==0.27.0 # Needed for API calls from Streamlit
15
- streamlit-autorefresh==1.0.1 # Added for polling notifications
 
1
  fastapi==0.111.0
2
  uvicorn[standard]==0.29.0
3
+ # gradio==4.29.0 # REMOVE
 
 
4
  bcrypt==4.1.3
5
+ passlib[bcrypt]==1.7.4
6
  python-dotenv==1.0.1
7
  databases[sqlite]==0.9.0
8
  sqlalchemy==2.0.29
 
10
  python-multipart==0.0.9
11
  itsdangerous==2.1.2
12
  websockets>=11.0.3,<13.0
13
+ aiofiles==23.2.1 # <-- ADD for StaticFiles
14
+ httpx==0.27.0 # <-- ADD (useful if backend needs to call itself, good practice)
static/css/style.css ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: sans-serif;
3
+ line-height: 1.6;
4
+ margin: 20px;
5
+ background-color: #f4f4f4;
6
+ }
7
+
8
+ h1, h2 {
9
+ color: #333;
10
+ }
11
+
12
+ .auth-section {
13
+ background-color: #fff;
14
+ padding: 20px;
15
+ margin-bottom: 20px;
16
+ border: 1px solid #ddd;
17
+ border-radius: 5px;
18
+ max-width: 400px;
19
+ }
20
+
21
+ #welcome-section {
22
+ background-color: #e7f3fe;
23
+ padding: 20px;
24
+ border: 1px solid #c8e1f8;
25
+ border-radius: 5px;
26
+ }
27
+
28
+
29
+ label {
30
+ display: inline-block;
31
+ margin-bottom: 5px;
32
+ }
33
+
34
+ input[type="email"],
35
+ input[type="password"] {
36
+ width: calc(100% - 22px); /* Account for padding/border */
37
+ padding: 10px;
38
+ margin-bottom: 10px;
39
+ border: 1px solid #ccc;
40
+ border-radius: 4px;
41
+ }
42
+
43
+ button {
44
+ background-color: #5cb85c;
45
+ color: white;
46
+ padding: 10px 15px;
47
+ border: none;
48
+ border-radius: 4px;
49
+ cursor: pointer;
50
+ font-size: 1em;
51
+ }
52
+
53
+ button:hover {
54
+ background-color: #4cae4c;
55
+ }
56
+
57
+ #logout-button {
58
+ background-color: #d9534f;
59
+ }
60
+ #logout-button:hover {
61
+ background-color: #c9302c;
62
+ }
63
+
64
+ .status-message {
65
+ margin-top: 15px;
66
+ color: red;
67
+ font-weight: bold;
68
+ }
69
+ .status-message.success {
70
+ color: green;
71
+ }
72
+
73
+ hr {
74
+ margin: 20px 0;
75
+ }
76
+
77
+ #notifications {
78
+ margin-top: 15px;
79
+ padding: 15px;
80
+ border: 1px dashed #aaa;
81
+ background-color: #f9f9f9;
82
+ min-height: 100px;
83
+ max-height: 300px;
84
+ overflow-y: auto;
85
+ }
86
+
87
+ #notifications p {
88
+ margin: 5px 0;
89
+ padding-bottom: 5px;
90
+ border-bottom: 1px solid #eee;
91
+ }
92
+ #notifications p:last-child {
93
+ border-bottom: none;
94
+ }
static/js/script.js ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const registerSection = document.getElementById('register-section');
2
+ const loginSection = document.getElementById('login-section');
3
+ const welcomeSection = document.getElementById('welcome-section');
4
+ const registerForm = document.getElementById('register-form');
5
+ const loginForm = document.getElementById('login-form');
6
+ const registerStatus = document.getElementById('register-status');
7
+ const loginStatus = document.getElementById('login-status');
8
+ const welcomeMessage = document.getElementById('welcome-message');
9
+ const logoutButton = document.getElementById('logout-button');
10
+ const notificationsDiv = document.getElementById('notifications');
11
+
12
+ const API_URL = '/api'; // Use relative path
13
+
14
+ let webSocket = null;
15
+ let authToken = localStorage.getItem('authToken'); // Load token on script start
16
+
17
+ // --- UI Control ---
18
+ function showSection(sectionId) {
19
+ registerSection.style.display = 'none';
20
+ loginSection.style.display = 'none';
21
+ welcomeSection.style.display = 'none';
22
+
23
+ const sectionToShow = document.getElementById(sectionId);
24
+ if (sectionToShow) {
25
+ sectionToShow.style.display = 'block';
26
+ } else {
27
+ loginSection.style.display = 'block'; // Default to login
28
+ }
29
+ }
30
+
31
+ function setStatus(element, message, isSuccess = false) {
32
+ element.textContent = message;
33
+ element.className = isSuccess ? 'status-message success' : 'status-message';
34
+ element.style.display = message ? 'block' : 'none';
35
+ }
36
+
37
+ // --- API Calls ---
38
+ async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
39
+ const headers = { 'Content-Type': 'application/json' };
40
+ if (token) {
41
+ headers['Authorization'] = `Bearer ${token}`;
42
+ }
43
+
44
+ const options = {
45
+ method: method,
46
+ headers: headers,
47
+ };
48
+
49
+ if (body && method !== 'GET') {
50
+ options.body = JSON.stringify(body);
51
+ }
52
+
53
+ try {
54
+ const response = await fetch(`${API_URL}${endpoint}`, options);
55
+ const data = await response.json(); // Try to parse JSON regardless of status
56
+
57
+ if (!response.ok) {
58
+ // Use detail from JSON if available, otherwise status text
59
+ const errorMessage = data?.detail || response.statusText || `HTTP error ${response.status}`;
60
+ console.error("API Error:", errorMessage);
61
+ throw new Error(errorMessage);
62
+ }
63
+ return data;
64
+ } catch (error) {
65
+ console.error(`Error during API request to ${endpoint}:`, error);
66
+ throw error; // Re-throw to be caught by caller
67
+ }
68
+ }
69
+
70
+
71
+ // --- WebSocket Handling ---
72
+ function connectWebSocket(token) {
73
+ if (!token) {
74
+ console.error("No token available for WebSocket connection.");
75
+ return;
76
+ }
77
+ if (webSocket && webSocket.readyState === WebSocket.OPEN) {
78
+ console.log("WebSocket already connected.");
79
+ return;
80
+ }
81
+
82
+ // Construct WebSocket URL dynamically (replace http/https with ws/wss)
83
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
84
+ const wsUrl = `${wsProtocol}//${window.location.host}${API_URL}/ws/${token}`;
85
+ console.log("Attempting to connect WebSocket:", wsUrl);
86
+
87
+
88
+ webSocket = new WebSocket(wsUrl);
89
+
90
+ webSocket.onopen = (event) => {
91
+ console.log("WebSocket connection opened:", event);
92
+ // Clear placeholder message on successful connect
93
+ if (notificationsDiv.querySelector('p em')) {
94
+ notificationsDiv.innerHTML = '';
95
+ }
96
+ };
97
+
98
+ webSocket.onmessage = (event) => {
99
+ console.log("WebSocket message received:", event.data);
100
+ try {
101
+ const messageData = JSON.parse(event.data);
102
+ if (messageData.type === 'new_user' && messageData.message) {
103
+ const p = document.createElement('p');
104
+ p.textContent = messageData.message;
105
+ // Add message to the top
106
+ notificationsDiv.insertBefore(p, notificationsDiv.firstChild);
107
+ // Limit number of messages shown (optional)
108
+ while (notificationsDiv.children.length > 10) {
109
+ notificationsDiv.removeChild(notificationsDiv.lastChild);
110
+ }
111
+
112
+ }
113
+ } catch (error) {
114
+ console.error("Error parsing WebSocket message:", error);
115
+ }
116
+ };
117
+
118
+ webSocket.onerror = (event) => {
119
+ console.error("WebSocket error:", event);
120
+ const p = document.createElement('p');
121
+ p.textContent = `WebSocket error occurred. Notifications may stop.`;
122
+ p.style.color = 'red';
123
+ notificationsDiv.insertBefore(p, notificationsDiv.firstChild);
124
+ };
125
+
126
+ webSocket.onclose = (event) => {
127
+ console.log("WebSocket connection closed:", event);
128
+ webSocket = null; // Reset WebSocket variable
129
+ // Optionally try to reconnect or inform user
130
+ // Do not clear notifications on close, user might want to see history
131
+ if (!event.wasClean) {
132
+ const p = document.createElement('p');
133
+ p.textContent = `WebSocket disconnected unexpectedly (Code: ${event.code}). Please refresh or log out/in.`;
134
+ p.style.color = 'orange';
135
+ notificationsDiv.insertBefore(p, notificationsDiv.firstChild);
136
+ }
137
+ };
138
+ }
139
+
140
+ function disconnectWebSocket() {
141
+ if (webSocket) {
142
+ console.log("Closing WebSocket connection.");
143
+ webSocket.close();
144
+ webSocket = null;
145
+ }
146
+ }
147
+
148
+ // --- Authentication Logic ---
149
+ async function handleLogin(email, password) {
150
+ setStatus(loginStatus, "Logging in...");
151
+ try {
152
+ const data = await apiRequest('/login', 'POST', { email, password });
153
+ if (data.access_token) {
154
+ authToken = data.access_token;
155
+ localStorage.setItem('authToken', authToken); // Store token
156
+ setStatus(loginStatus, ""); // Clear status
157
+ await showWelcomePage(); // Fetch user info and show welcome
158
+ } else {
159
+ throw new Error("Login failed: No token received.");
160
+ }
161
+ } catch (error) {
162
+ setStatus(loginStatus, `Login failed: ${error.message}`);
163
+ }
164
+ }
165
+
166
+ async function handleRegister(email, password) {
167
+ setStatus(registerStatus, "Registering...");
168
+ try {
169
+ const data = await apiRequest('/register', 'POST', { email, password });
170
+ setStatus(registerStatus, `Registration successful for ${data.email}! Please log in.`, true);
171
+ registerForm.reset(); // Clear form
172
+ showSection('login-section'); // Switch to login
173
+ } catch (error) {
174
+ setStatus(registerStatus, `Registration failed: ${error.message}`);
175
+ }
176
+ }
177
+
178
+ async function showWelcomePage() {
179
+ if (!authToken) {
180
+ showSection('login-section');
181
+ return;
182
+ }
183
+ try {
184
+ // Fetch user details using the token
185
+ const user = await apiRequest('/users/me', 'GET', null, authToken);
186
+ welcomeMessage.textContent = `Welcome, ${user.email}!`;
187
+ showSection('welcome-section');
188
+ connectWebSocket(authToken); // Connect WS after successful login and user fetch
189
+ } catch (error) {
190
+ // If fetching user fails (e.g., invalid/expired token), logout
191
+ console.error("Failed to fetch user details:", error);
192
+ handleLogout();
193
+ }
194
+ }
195
+
196
+ function handleLogout() {
197
+ authToken = null;
198
+ localStorage.removeItem('authToken');
199
+ disconnectWebSocket();
200
+ setStatus(loginStatus, ""); // Clear any previous login errors
201
+ showSection('login-section');
202
+ }
203
+
204
+
205
+ // --- Event Listeners ---
206
+ registerForm.addEventListener('submit', (e) => {
207
+ e.preventDefault();
208
+ const email = document.getElementById('reg-email').value;
209
+ const password = document.getElementById('reg-password').value;
210
+ const confirmPassword = document.getElementById('reg-confirm-password').value;
211
+ if (password !== confirmPassword) {
212
+ setStatus(registerStatus, "Passwords do not match.");
213
+ return;
214
+ }
215
+ if (password.length < 8) {
216
+ setStatus(registerStatus, "Password must be at least 8 characters.");
217
+ return;
218
+ }
219
+ handleRegister(email, password);
220
+ });
221
+
222
+ loginForm.addEventListener('submit', (e) => {
223
+ e.preventDefault();
224
+ const email = document.getElementById('login-email').value;
225
+ const password = document.getElementById('login-password').value;
226
+ handleLogin(email, password);
227
+ });
228
+
229
+ logoutButton.addEventListener('click', handleLogout);
230
+
231
+
232
+ // --- Initial Page Load Logic ---
233
+ document.addEventListener('DOMContentLoaded', () => {
234
+ if (authToken) {
235
+ console.log("Token found, attempting to show welcome page.");
236
+ showWelcomePage(); // Try to fetch user info and show welcome
237
+ } else {
238
+ console.log("No token found, showing login page.");
239
+ showSection('login-section'); // Show login if no token
240
+ }
241
+ });
streamlit_app.py DELETED
@@ -1,269 +0,0 @@
1
- # streamlit_app.py
2
- import streamlit as st
3
- from streamlit_autorefresh import st_autorefresh # For periodic refresh
4
- import httpx
5
- import asyncio
6
- import websockets
7
- import json
8
- import threading
9
- import queue
10
- import logging
11
- import time
12
- import os
13
-
14
- # Import backend components
15
- from app import crud, models, schemas, auth, dependencies
16
- from app.database import ensure_db_and_table_exist # Sync function
17
- from app.websocket import manager # Import the manager instance
18
-
19
- # FastAPI imports for mounting
20
- from fastapi import FastAPI, Depends, HTTPException, status
21
- from fastapi.routing import Mount
22
- from fastapi.staticfiles import StaticFiles
23
- from app.api import router as api_router # Import the specific API router
24
-
25
- # --- Logging ---
26
- logging.basicConfig(level=logging.INFO)
27
- logger = logging.getLogger(__name__)
28
-
29
- # --- Configuration ---
30
- # Use environment variable or default for local vs deployed API endpoint
31
- # Since we are mounting FastAPI within Streamlit for the HF Space deployment:
32
- API_BASE_URL = "http://127.0.0.1:7860/api" # Calls within the same process
33
- WS_BASE_URL = API_BASE_URL.replace("http", "ws")
34
-
35
- # --- Ensure DB exists on first run ---
36
- # This runs once per session/process start
37
- ensure_db_and_table_exist()
38
-
39
- # --- FastAPI Mounting Setup ---
40
- # Create a FastAPI instance (separate from the Streamlit one)
41
- # We won't run this directly with uvicorn, but Streamlit uses it internally
42
- api_app = FastAPI(title="Backend API") # Can add lifespan if needed for API-specific setup later
43
- api_app.include_router(api_router, prefix="/api")
44
-
45
- # Mount the FastAPI app within Streamlit's internal Tornado server
46
- # This requires monkey-patching or using available hooks if Streamlit allows.
47
- # Simpler approach for HF Space: Run FastAPI separately is cleaner if possible.
48
- # Reverting to the idea that for this HF Space demo, API calls will be internal HTTP requests.
49
-
50
- # --- WebSocket Listener Thread ---
51
- stop_event = threading.Event()
52
- notification_queue = queue.Queue()
53
-
54
- def websocket_listener(token: str):
55
- """Runs in a background thread to listen for WebSocket messages."""
56
- logger.info(f"[WS Thread] Listener started for token: {token[:10]}...")
57
- ws_url = f"{WS_BASE_URL}/ws/{token}"
58
-
59
- async def listen():
60
- try:
61
- async with websockets.connect(ws_url, open_timeout=10.0) as ws:
62
- logger.info(f"[WS Thread] Connected to {ws_url}")
63
- st.session_state['ws_connected'] = True
64
- while not stop_event.is_set():
65
- try:
66
- message = await asyncio.wait_for(ws.recv(), timeout=1.0) # Check stop_event frequently
67
- logger.info(f"[WS Thread] Received message: {message[:100]}...")
68
- try:
69
- data = json.loads(message)
70
- if data.get("type") == "new_user":
71
- notification = schemas.Notification(**data)
72
- notification_queue.put(notification.message) # Put message in queue
73
- logger.info("[WS Thread] Put notification in queue.")
74
- except json.JSONDecodeError:
75
- logger.error("[WS Thread] Failed to decode JSON.")
76
- except Exception as e:
77
- logger.error(f"[WS Thread] Error processing message: {e}")
78
-
79
- except asyncio.TimeoutError:
80
- continue # No message, check stop_event again
81
- except websockets.ConnectionClosed:
82
- logger.warning("[WS Thread] Connection closed.")
83
- break # Exit loop if closed
84
- except Exception as e:
85
- logger.error(f"[WS Thread] Connection failed or error: {e}")
86
- finally:
87
- logger.info("[WS Thread] Listener loop finished.")
88
- st.session_state['ws_connected'] = False
89
-
90
- try:
91
- asyncio.run(listen())
92
- except Exception as e:
93
- logger.error(f"[WS Thread] asyncio.run error: {e}")
94
- logger.info("[WS Thread] Listener thread exiting.")
95
-
96
-
97
- # --- Streamlit UI ---
98
-
99
- st.set_page_config(layout="wide")
100
-
101
- # --- Initialize Session State ---
102
- if 'logged_in' not in st.session_state:
103
- st.session_state.logged_in = False
104
- if 'token' not in st.session_state:
105
- st.session_state.token = None
106
- if 'user_email' not in st.session_state:
107
- st.session_state.user_email = None
108
- if 'notifications' not in st.session_state:
109
- st.session_state.notifications = []
110
- if 'ws_thread' not in st.session_state:
111
- st.session_state.ws_thread = None
112
- if 'ws_connected' not in st.session_state:
113
- st.session_state.ws_connected = False
114
-
115
- # --- Notification Processing ---
116
- new_notifications = []
117
- while not notification_queue.empty():
118
- try:
119
- msg = notification_queue.get_nowait()
120
- new_notifications.append(msg)
121
- except queue.Empty:
122
- break
123
-
124
- if new_notifications:
125
- logger.info(f"Processing {len(new_notifications)} notifications from queue.")
126
- # Prepend new notifications to the session state list
127
- current_list = st.session_state.notifications
128
- st.session_state.notifications = new_notifications + current_list
129
- # Limit history
130
- if len(st.session_state.notifications) > 15:
131
- st.session_state.notifications = st.session_state.notifications[:15]
132
- # No explicit rerun needed here, Streamlit should rerun due to state change (?)
133
- # or due to autorefresh below.
134
-
135
- # --- Auto Refresh ---
136
- # Refresh every 2 seconds to check the queue and update display
137
- count = st_autorefresh(interval=2000, limit=None, key="notifrefresh")
138
-
139
- # --- API Client ---
140
- client = httpx.AsyncClient(base_url=API_BASE_URL, timeout=10.0)
141
-
142
- # --- Helper Functions for API Calls ---
143
- async def api_register(email, password):
144
- try:
145
- response = await client.post("/register", json={"email": email, "password": password})
146
- response.raise_for_status()
147
- return {"success": True, "data": response.json()}
148
- except httpx.HTTPStatusError as e:
149
- detail = e.response.json().get("detail", e.response.text)
150
- logger.error(f"API Register Error: {e.response.status_code} - {detail}")
151
- return {"success": False, "error": f"API Error: {detail}"}
152
- except Exception as e:
153
- logger.exception("Register call failed")
154
- return {"success": False, "error": f"Request failed: {e}"}
155
-
156
- async def api_login(email, password):
157
- try:
158
- response = await client.post("/login", json={"email": email, "password": password})
159
- response.raise_for_status()
160
- return {"success": True, "data": response.json()}
161
- except httpx.HTTPStatusError as e:
162
- detail = e.response.json().get("detail", e.response.text)
163
- logger.error(f"API Login Error: {e.response.status_code} - {detail}")
164
- return {"success": False, "error": f"API Error: {detail}"}
165
- except Exception as e:
166
- logger.exception("Login call failed")
167
- return {"success": False, "error": f"Request failed: {e}"}
168
-
169
- # --- UI Rendering ---
170
- st.title("Authentication & Notification App (Streamlit)")
171
-
172
- if not st.session_state.logged_in:
173
- st.sidebar.header("Login or Register")
174
- login_tab, register_tab = st.sidebar.tabs(["Login", "Register"])
175
-
176
- with login_tab:
177
- with st.form("login_form"):
178
- login_email = st.text_input("Email", key="login_email")
179
- login_password = st.text_input("Password", type="password", key="login_password")
180
- login_button = st.form_submit_button("Login")
181
-
182
- if login_button:
183
- if not login_email or not login_password:
184
- st.error("Please enter email and password.")
185
- else:
186
- result = asyncio.run(api_login(login_email, login_password)) # Run async in sync context
187
- if result["success"]:
188
- token = result["data"]["access_token"]
189
- # Attempt to get user info immediately - needs modification if /users/me requires auth header
190
- # For simplicity, just store email from login form for now
191
- st.session_state.logged_in = True
192
- st.session_state.token = token
193
- st.session_state.user_email = login_email # Store email used for login
194
- st.session_state.notifications = [] # Clear old notifications
195
-
196
- # Start WebSocket listener thread
197
- stop_event.clear() # Ensure stop event is clear
198
- thread = threading.Thread(target=websocket_listener, args=(token,), daemon=True)
199
- st.session_state.ws_thread = thread
200
- thread.start()
201
- logger.info("Login successful, WS thread started.")
202
- st.rerun() # Rerun immediately to switch view
203
- else:
204
- st.error(f"Login failed: {result['error']}")
205
-
206
- with register_tab:
207
- with st.form("register_form"):
208
- reg_email = st.text_input("Email", key="reg_email")
209
- reg_password = st.text_input("Password", type="password", key="reg_password")
210
- reg_confirm = st.text_input("Confirm Password", type="password", key="reg_confirm")
211
- register_button = st.form_submit_button("Register")
212
-
213
- if register_button:
214
- if not reg_email or not reg_password or not reg_confirm:
215
- st.error("Please fill all fields.")
216
- elif reg_password != reg_confirm:
217
- st.error("Passwords do not match.")
218
- elif len(reg_password) < 8:
219
- st.error("Password must be at least 8 characters.")
220
- else:
221
- result = asyncio.run(api_register(reg_email, reg_password))
222
- if result["success"]:
223
- st.success(f"Registration successful for {result['data']['email']}! Please log in.")
224
- else:
225
- st.error(f"Registration failed: {result['error']}")
226
-
227
- else: # Logged In View
228
- st.sidebar.header(f"Welcome, {st.session_state.user_email}!")
229
- if st.sidebar.button("Logout"):
230
- logger.info("Logout requested.")
231
- # Stop WebSocket thread
232
- if st.session_state.ws_thread and st.session_state.ws_thread.is_alive():
233
- logger.info("Signalling WS thread to stop.")
234
- stop_event.set()
235
- st.session_state.ws_thread.join(timeout=2.0) # Wait briefly for thread exit
236
- if st.session_state.ws_thread.is_alive():
237
- logger.warning("WS thread did not exit cleanly.")
238
- # Clear session state
239
- st.session_state.logged_in = False
240
- st.session_state.token = None
241
- st.session_state.user_email = None
242
- st.session_state.notifications = []
243
- st.session_state.ws_thread = None
244
- st.session_state.ws_connected = False
245
- logger.info("Session cleared.")
246
- st.rerun()
247
-
248
- st.header("Dashboard")
249
- # Display notifications
250
- st.subheader("Real-time Notifications")
251
- ws_status = "Connected" if st.session_state.ws_connected else "Disconnected"
252
- st.caption(f"WebSocket Status: {ws_status}")
253
-
254
- if st.session_state.notifications:
255
- for i, msg in enumerate(st.session_state.notifications):
256
- st.info(f"{msg}", icon="🔔")
257
- else:
258
- st.text("No new notifications.")
259
-
260
- # Add a button to manually check queue/refresh if needed
261
- # if st.button("Check for notifications"):
262
- # st.rerun() # Force rerun which includes queue check
263
-
264
-
265
- # --- Final Cleanup ---
266
- # Ensure httpx client is closed if script exits abnormally
267
- # (This might not always run depending on how Streamlit terminates)
268
- # Ideally handled within context managers if used more extensively
269
- # asyncio.run(client.aclose())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/index.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Auth App</title>
7
+ <link rel="stylesheet" href="/static/css/style.css">
8
+ </head>
9
+ <body>
10
+ <h1>Auth & Notification App</h1>
11
+
12
+ <!-- Registration Section -->
13
+ <div id="register-section" class="auth-section">
14
+ <h2>Register</h2>
15
+ <form id="register-form">
16
+ <label for="reg-email">Email:</label>
17
+ <input type="email" id="reg-email" name="email" required><br><br>
18
+ <label for="reg-password">Password:</label>
19
+ <input type="password" id="reg-password" name="password" required minlength="8"><br><br>
20
+ <label for="reg-confirm-password">Confirm Password:</label>
21
+ <input type="password" id="reg-confirm-password" name="confirm_password" required minlength="8"><br><br>
22
+ <button type="submit">Register</button>
23
+ </form>
24
+ <p id="register-status" class="status-message"></p>
25
+ <p>Already have an account? <a href="#" onclick="showSection('login-section')">Login here</a></p>
26
+ </div>
27
+
28
+ <!-- Login Section -->
29
+ <div id="login-section" class="auth-section" style="display: none;">
30
+ <h2>Login</h2>
31
+ <form id="login-form">
32
+ <label for="login-email">Email:</label>
33
+ <input type="email" id="login-email" name="email" required><br><br>
34
+ <label for="login-password">Password:</label>
35
+ <input type="password" id="login-password" name="password" required><br><br>
36
+ <button type="submit">Login</button>
37
+ </form>
38
+ <p id="login-status" class="status-message"></p>
39
+ <p>Don't have an account? <a href="#" onclick="showSection('register-section')">Register here</a></p>
40
+ </div>
41
+
42
+ <!-- Welcome Section (shown after login) -->
43
+ <div id="welcome-section" style="display: none;">
44
+ <h2>Welcome!</h2>
45
+ <p id="welcome-message">Welcome, user!</p>
46
+ <button id="logout-button">Logout</button>
47
+ <hr>
48
+ <h2>Real-time Notifications</h2>
49
+ <div id="notifications">
50
+ <p><em>Notifications will appear here...</em></p>
51
+ </div>
52
+ </div>
53
+
54
+ <script src="/static/js/script.js"></script>
55
+ </body>
56
+ </html>