Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/create_fake_users.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""Local seed script: insert a handful of fake users for development.

Run once after pointing the backend at a fresh database (``python
create_fake_users.py``). Idempotent — users with an existing email are
skipped rather than duplicated. Not used in production.
"""
from database import SessionLocal, engine, Base
from models import User
import uuid
Expand Down
12 changes: 11 additions & 1 deletion backend/database.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# database.py
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
Expand All @@ -15,9 +14,20 @@
Base = declarative_base()

def get_db():
"""
Used as a FastAPI ``Depends(get_db)`` so each request gets its own
session and the connection is always returned to the pool.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()


# Limit pdoc's rendered surface to the public API. The hidden symbols
# (engine, SessionLocal, SQLALCHEMY_DATABASE_URL) are still importable
# in Python; this just keeps their values out of the docs HTML, since
# their reprs would leak the local connection string.
__all__ = ["Base", "get_db"]

26 changes: 26 additions & 0 deletions backend/export_openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Dump the FastAPI app's OpenAPI schema to docs/api/openapi.json.

Run from the backend directory with the project venv active:

python export_openapi.py

Importing ``main`` triggers the schema bootstrap (``create_all`` plus
``ensure_schema_updates``), so a working DATABASE_URL is required —
the same one you use for local development.
"""
import json
from pathlib import Path

from main import app

OUT_PATH = Path(__file__).resolve().parent.parent / "docs" / "api" / "openapi.json"


def main() -> None:
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(json.dumps(app.openapi(), indent=2))
print(f"Wrote {OUT_PATH}")


if __name__ == "__main__":
main()
12 changes: 9 additions & 3 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
"""
- Wires up the FastAPI app and CORS for the React frontend.
- Defines Pydantic request/response schemas alongside their routes.
- Implements all REST endpoints, grouped by feature: users & profile,
courses & enrollment, posts & moderation, conversations & messages,
study groups, study sessions, and Google Calendar availability sync.
- Runs lightweight additive schema migrations at startup via
``ensure_schema_updates`` so local dev databases stay compatible.
"""
from fastapi import FastAPI, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, text, or_
Expand All @@ -8,9 +17,6 @@
from typing import List, Optional
from datetime import datetime, timedelta, timezone

# WARNING: This deletes all data!
# Uncomment once to reset the schema for your new multi-class structure.
# Base.metadata.drop_all(bind=engine)

def ensure_schema_updates():
"""Apply minimal additive schema updates for local development."""
Expand Down
54 changes: 54 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
"""
High-level entity map:

- ``User`` — a person (student, TA, or admin).
- ``Course`` + ``Enrollment`` — a class and its roster.
- ``Post`` + ``PostVote`` — discussion/resource posts and their votes.
- ``StudyGroup`` + ``StudyGroupMember`` — long-lived groups inside a course.
- ``StudySession`` + ``StudySessionInvitee`` — scheduled meetings (solo or group).
- ``UserAvailability`` — busy/free blocks, typically synced from Google Calendar.
- ``Conversation`` + ``ConversationParticipant`` + ``Message`` — chat.
"""
from database import Base
from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
Expand All @@ -6,12 +17,19 @@


class UserRole(str, enum.Enum):
"""Allowed values for ``User.role``. Stored as a plain string column."""
STUDENT = "Student"
TA = "TA"
ADMIN = "Admin"


class User(Base):
"""A StudySync user, keyed by their Firebase Auth UID.

Holds profile basics plus an optional Google Calendar token used by
the calendar-sync feature. All ownership/authorship foreign keys in
other tables point at ``firebase_uid``.
"""
__tablename__ = "users"

# Firebase UID is now the primary key (string)
Expand All @@ -31,6 +49,7 @@ class User(Base):


class Course(Base):
"""A class/course that students can enroll in and post to."""
__tablename__ = "courses"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -49,6 +68,7 @@ class Course(Base):


class Enrollment(Base):
"""Join table linking a ``User`` to a ``Course`` they are enrolled in."""
__tablename__ = "enrollments"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -61,6 +81,11 @@ class Enrollment(Base):


class Conversation(Base):
"""A chat thread, either 1:1 (``is_group`` False) or a named group chat.

Optionally scoped to a course via ``course_id`` so course-specific
chats can be filtered out of personal DMs.
"""
__tablename__ = "conversations"

conversation_id = Column(Integer, primary_key=True, index=True)
Expand All @@ -75,6 +100,7 @@ class Conversation(Base):


class ConversationParticipant(Base):
"""Membership of a user in a conversation; controls who can read/send."""
__tablename__ = "conversation_participants"

participant_id = Column(Integer, primary_key=True, index=True)
Expand All @@ -88,6 +114,7 @@ class ConversationParticipant(Base):


class Message(Base):
"""A single chat message inside a ``Conversation``."""
__tablename__ = "messages"

message_id = Column("id", Integer, primary_key=True, index=True)
Expand All @@ -102,6 +129,12 @@ class Message(Base):


class Post(Base):
"""A discussion or resource post within a course.

``score`` is a denormalized vote tally maintained alongside
``PostVote`` rows. ``is_flagged`` lets moderators hide a post
without deleting it.
"""
__tablename__ = "posts"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -120,6 +153,7 @@ class Post(Base):


class PostVote(Base):
"""One user's upvote (+1) or downvote (-1) on a ``Post``."""
__tablename__ = "post_votes"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -131,6 +165,11 @@ class PostVote(Base):


class StudyGroup(Base):
"""A persistent study group inside a course.

Holds the long-lived membership (``StudyGroupMember``) and any
scheduled meetings (``StudySession``).
"""
__tablename__ = "study_groups"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -144,6 +183,12 @@ class StudyGroup(Base):


class StudySession(Base):
"""A scheduled study meeting.

``session_type`` is ``"solo"`` for personal study blocks or
``"group"`` when tied to a ``StudyGroup`` via ``group_id``.
Invitees (for group sessions) live in ``StudySessionInvitee``.
"""
__tablename__ = "study_sessions"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -161,6 +206,7 @@ class StudySession(Base):


class StudyGroupMember(Base):
"""A user's membership in a ``StudyGroup``, keyed by email."""
__tablename__ = "study_group_members"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -172,6 +218,13 @@ class StudyGroupMember(Base):


class UserAvailability(Base):
"""A busy/free time block for a user.

Most rows are imported from Google Calendar (``source =
"google_calendar"``); rows tied to a StudySession reflect blocks
StudySync itself created. Used by the scheduler to find overlap
when proposing meeting times.
"""
__tablename__ = "user_availability"

id = Column(Integer, primary_key=True, index=True)
Expand All @@ -184,6 +237,7 @@ class UserAvailability(Base):


class StudySessionInvitee(Base):
"""An email invited to a ``StudySession``; the invitee may not yet be a registered user."""
__tablename__ = "study_session_invitees"

id = Column(Integer, primary_key=True, index=True)
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
"""Shared pytest fixtures for the StudySync backend test suite.

Provides:

- ``_guard_test_db`` (session-scoped): refuses to run if ``DATABASE_URL``
doesn't point at a database whose name contains ``test``, so the
destructive ``db`` fixture can never wipe a developer's real data.
Override with ``ALLOW_DB_RESET_FOR_TESTS=1`` only if you know what
you're doing.
- ``client``: a FastAPI ``TestClient`` lazily imported after the guard.
- ``db``: a clean SQLAlchemy session, with the schema dropped and
recreated before each test that requests it.
"""
import os

import pytest
Expand Down
7 changes: 7 additions & 0 deletions backend/tests/test_authentication.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""Tests for the User Authentication & Profile Management requirement.

Covers the Firebase-sync upsert path (``POST /sync-user``), profile
fetch and update endpoints, and a Hypothesis property-based pass over
``/sync-user`` to fuzz unusual but valid inputs.
"""
import pytest
import models
from fastapi.testclient import TestClient
Expand All @@ -7,6 +13,7 @@


def _seed_test_users(db):
"""Insert one Student, one TA, and one Admin so role-gated paths can be exercised."""
import models

student = models.User(
Expand Down
8 changes: 8 additions & 0 deletions backend/tests/test_messaging_integrity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
"""Tests for messaging-data integrity.

Verifies that the messaging endpoints reject malformed input (e.g.
empty/whitespace-only message bodies) before persisting anything,
which keeps the conversation tables free of garbage rows.
"""
import pytest


def _seed_users_course_enrollments(db):
"""Create a TA, two students, a course, and enroll both students in it."""
import models

ta = models.User(
Expand Down Expand Up @@ -45,6 +52,7 @@ def _seed_users_course_enrollments(db):


def test_send_message_rejects_empty_content(client, db):
"""POST /messages with whitespace-only content must return 400, not persist."""
seeded = _seed_users_course_enrollments(db)
course_id = seeded["course"].id

Expand Down
37 changes: 21 additions & 16 deletions backend/tests/test_permissions.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,47 @@
"""Permission and inbox-shape tests against the live FastAPI app.

Two concerns:

- Role gating: a Student must not be able to delete a post (only TAs
and Admins can).
- Inbox/message endpoint contracts: response shape and required-param
validation for ``/conversations/inbox/global`` and ``/messages``,
plus a Hypothesis property-based pass over the inbox endpoint.

These tests share a module-level ``TestClient`` and do not use the
``db`` fixture from conftest, so they run against whatever database
``main.app`` is configured for.
"""
import pytest
from fastapi.testclient import TestClient
from main import app
from hypothesis import given, strategies as st

client = TestClient(app)


def test_student_cannot_delete_post():
"""
Requirement: Permissions (TA vs Student)
Verifies that a Student role receives a 403 Forbidden when attempting to delete.
"""
# 1. We assume a post with ID 1 exists in the DB for this test
# 2. We pass a user_uid that belongs to a 'Student'
# Note: You may need to ensure this user exists in your test DB or mock the DB call
"""DELETE /posts/{id} as a Student must return 403 with the expected detail."""
response = client.delete("/posts/1?user_uid=student_user_123")

# Assert that the backend blocks the action
assert response.status_code == 403
assert response.json()["detail"] == "Only TAs or Admins can delete posts."

def test_get_global_inbox_structure():
"""
Requirement: Core Functionality (Global Inbox)
Verifies the inbox returns the expected data structure.
"""
"""Global inbox must return a JSON list (the conversation feed shape)."""
response = client.get("/conversations/inbox/global?user_uid=test_user")
assert response.status_code == 200
assert isinstance(response.json(), list)

def test_get_global_inbox_empty():
"""Verifies that a new user with no chats gets an empty list, not an error."""
"""A user with no conversations should get an empty list, not a 404 or error."""
response = client.get("/conversations/inbox/global?user_uid=new_user_999")
assert response.status_code == 200
assert response.json() == []

def test_get_class_discussion_messages():
"""Verifies fetching public group messages for a specific course."""
# Even for group chats, user1 and user2 must be present to pass validation
"""Fetching the public class group chat (is_group=true) must return a list."""
params = {
"user1": "test_user",
"user2": "GROUP_CHAT",
Expand All @@ -47,9 +52,9 @@ def test_get_class_discussion_messages():

assert response.status_code == 200
assert isinstance(response.json(), list)

def test_global_inbox_missing_params():
"""Verifies the backend returns 422 Unprocessable Entity if UID is missing."""
"""Global inbox without the required user_uid query param must 422 (FastAPI validation)."""
response = client.get("/conversations/inbox/global") # No query param
assert response.status_code == 422

Expand Down
Loading