Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
6283fcc
initial small setup
kunzhutich Aug 28, 2026
528ffd7
Create test.txt
kunzhutich Aug 28, 2026
6a84f1b
connor test
ConnorVandrush Aug 28, 2026
1ea7748
deleted folders
ConnorVandrush Aug 28, 2026
a2043a5
test commit
ConnorVandrush Aug 28, 2026
138a35c
Merge pull request #1 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 28, 2026
a1181c2
Add header component
ConnorVandrush Aug 28, 2026
a190f3e
Merge pull request #2 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 28, 2026
ee2530c
fixed
ConnorVandrush Aug 28, 2026
646123e
Merge pull request #3 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 28, 2026
3c395e8
initial BE layer ready but not tested
kunzhutich Aug 28, 2026
b8c1e55
update
BrendanGeary2020 Aug 28, 2026
9392a12
seeding data
kunzhutich Aug 28, 2026
2ee39f6
update
BrendanGeary2020 Aug 28, 2026
8b27a99
moved infra folder out so that it can capture FE as well
kunzhutich Aug 28, 2026
d0f4ffb
Merge branch 'hanruiwang-backend-schemas'
kunzhutich Aug 28, 2026
4633442
trainer side bar
BrendanGeary2020 Aug 28, 2026
5a6a6bb
Merge branch 'master' into Brendan-Geary-Frontend
BrendanGeary2020 Aug 28, 2026
96cda40
updated dependencies
kunzhutich Aug 28, 2026
388509e
Work in progress
ConnorVandrush Aug 28, 2026
206cd72
Fix FE/BE field casing mismatch and reserved-TLD seed emails
kunzhutich Aug 28, 2026
50a93ef
Merge branch 'master' into connor-dev-branch
ConnorVandrush Aug 28, 2026
4f564f1
Merge pull request #4 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 28, 2026
2e734e7
Implement manager (Trainer) dashboard: create tasks, view cohort prog…
kunzhutich Aug 28, 2026
679d6bc
logout worked for both user types
BrendanGeary2020 Aug 28, 2026
15a3131
imlplement trainee
ConnorVandrush Aug 28, 2026
1a07605
Merge branch 'connor-dev-branch' of https://github.com/kunzhutich/wor…
ConnorVandrush Aug 28, 2026
0a2bf32
Merge branch 'master' into connor-dev-branch
ConnorVandrush Aug 28, 2026
65c8e08
Merge pull request #5 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 28, 2026
dd47a44
Merge branch 'hanruiwang-backend-schemas'
kunzhutich Aug 28, 2026
3a74263
feat: add reusable development seed data
MUzairAnees Aug 28, 2026
e1e24e8
Fix merge fallout: restore auth flow after TraineeStore -> TraineeSli…
kunzhutich Aug 28, 2026
f4e028f
Merge remote-tracking branch 'origin/master' into muzairanees-expand-…
MUzairAnees Aug 28, 2026
1c2f443
Merge pull request #6 from kunzhutich/muzairanees-expand-seed-data
MUzairAnees Aug 28, 2026
6acd854
Deployed version
ConnorVandrush Aug 28, 2026
07a2f06
Merge branch 'master' into connor-dev-branch
ConnorVandrush Aug 28, 2026
c16b980
Merge pull request #7 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 28, 2026
a42c74a
no need for this file
kunzhutich Aug 28, 2026
5a7f15f
Merge branch 'master' of https://github.com/kunzhutich/workshops
kunzhutich Aug 28, 2026
7da6295
Good version
ConnorVandrush Aug 29, 2026
1f8c93e
Merge pull request #8 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 29, 2026
4c1ca11
Good version v2
ConnorVandrush Aug 30, 2026
7f1b4df
Merge pull request #9 from kunzhutich/connor-dev-branch
ConnorVandrush Aug 30, 2026
18c886c
Add light/dark mode toggle and a profile settings modal
kunzhutich Aug 30, 2026
b754193
Rework theme palette (beige/brown light, navy/charcoal dark) and fix …
kunzhutich Aug 30, 2026
2367250
Recolor sidebar (light brown/grey), lighten dark-mode accordion, and …
kunzhutich Aug 30, 2026
bcd615d
Merge pull request #10 from kunzhutich/hanruiwang-bonus-features
kunzhutich Aug 30, 2026
c2555ea
Merge upstream/master, resolve package-lock.json conflict by removing…
kunzhutich Sep 1, 2026
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
2,796 changes: 0 additions & 2,796 deletions package-lock.json

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# NoticeBoardTracker API

FastAPI backend, layered: `api` (controllers) -> `services` (business logic) -> `repositories` (DB access) -> `models` (ORM). `schemas` holds the Pydantic request/response DTOs.

## Setup

1. `python -m venv .venv` then activate it (`.venv\Scripts\activate` on Windows, `source .venv/bin/activate` elsewhere)
2. `pip install -r requirements.txt`
3. Copy `.env.example` to `.env`, fill in `DATABASE_URL` (from whoever set up Postgres) and a `JWT_SECRET_KEY`
4. `alembic upgrade head` to create the schema
5. `uvicorn app.main:app --reload`

Interactive API docs: `http://localhost:8000/docs`

## Layout

- `app/models` — SQLAlchemy ORM classes, the DB shape
- `app/schemas` — Pydantic DTOs, the API shape. All inherit `CamelModel` (`app/schemas/base.py`), so Python stays snake_case internally and JSON on the wire is camelCase automatically
- `app/repositories` — DB queries only, no business logic
- `app/services` — business logic; `TaskAssignmentFactory` is where a task fans out into one `task_assignments` row per trainee
- `app/api/v1` — routers, thin HTTP layer only
- `app/dependencies/auth.py` — JWT decode + role guards (`get_current_manager`, `get_current_trainee`)
- `alembic/versions` — migrations, the source of truth for DB structure; don't hand-edit the shared DB, add a migration instead

## Adding a migration

After changing a model in `app/models/`:

```
alembic revision --autogenerate -m "describe the change"
alembic upgrade head
```

Review the generated file before committing — autogenerate doesn't always get constraints/enums exactly right.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from app.core.config import get_settings
from app.core.database import Base
from app.models import * # noqa: F401,F403 - register all models on Base.metadata

config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)

# configparser treats "%" as interpolation syntax (URL-encoded passwords hit
# this), so escape it before handing the URL to alembic's Config.
config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%"))

target_metadata = Base.metadata


def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""initial schema

Revision ID: 0001
Revises:
Create Date: 2026-08-28

"""
from alembic import op
import sqlalchemy as sa

revision = "0001"
down_revision = None
branch_labels = None
depends_on = None

user_role = sa.Enum("trainee", "manager", "hr", name="user_role")
urgency_level = sa.Enum("low", "medium", "high", "urgent", name="urgency_level")


def upgrade() -> None:
op.create_table(
"cohorts",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("manager_id", sa.Integer, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)

op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("email", sa.String(255), unique=True, nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("full_name", sa.String(255), nullable=False),
sa.Column("role", user_role, nullable=False),
sa.Column("cohort_id", sa.Integer, sa.ForeignKey("cohorts.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)

op.create_foreign_key("fk_cohorts_manager_id", "cohorts", "users", ["manager_id"], ["id"])

op.create_table(
"tasks",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("cohort_id", sa.Integer, sa.ForeignKey("cohorts.id"), nullable=True),
sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("urgency", urgency_level, nullable=False, server_default="medium"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)

op.create_table(
"subtasks",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("order_index", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)

op.create_table(
"task_assignments",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("trainee_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("current_percentage", sa.SmallInteger, nullable=False, server_default="0"),
sa.Column("last_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("task_id", "trainee_id", name="uq_task_assignment"),
sa.CheckConstraint("current_percentage BETWEEN 0 AND 100", name="ck_task_assignment_pct"),
)

op.create_table(
"subtask_completions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("subtask_id", sa.Integer, sa.ForeignKey("subtasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("trainee_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("is_completed", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("subtask_id", "trainee_id", name="uq_subtask_completion"),
)

op.create_table(
"progress_updates",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"task_assignment_id",
sa.Integer,
sa.ForeignKey("task_assignments.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("percentage", sa.SmallInteger, nullable=False),
sa.Column("comment", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.CheckConstraint("percentage BETWEEN 0 AND 100", name="ck_progress_update_pct"),
)

op.create_index("idx_task_assignments_task", "task_assignments", ["task_id"])
op.create_index("idx_task_assignments_trainee", "task_assignments", ["trainee_id"])
op.create_index("idx_progress_updates_assignment", "progress_updates", ["task_assignment_id", "created_at"])


def downgrade() -> None:
op.drop_table("progress_updates")
op.drop_table("subtask_completions")
op.drop_table("task_assignments")
op.drop_table("subtasks")
op.drop_table("tasks")
op.drop_constraint("fk_cohorts_manager_id", "cohorts", type_="foreignkey")
op.drop_table("users")
op.drop_table("cohorts")
urgency_level.drop(op.get_bind(), checkfirst=True)
user_role.drop(op.get_bind(), checkfirst=True)
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""add manager_id to users

Revision ID: 0002
Revises: 0001
Create Date: 2026-08-29

"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None


def upgrade() -> None:
# Add manager_id to users.
#
# This is nullable because manager users do not belong
# to another manager. Trainee users will have their
# manager's user.id stored here.
op.add_column(
"users",
sa.Column(
"manager_id",
sa.Integer(),
nullable=True,
),
)

# Self-referencing foreign key:
#
# users.manager_id -> users.id
op.create_foreign_key(
"fk_users_manager_id",
"users",
"users",
["manager_id"],
["id"],
)


def downgrade() -> None:
# Remove the self-referencing foreign key first.
op.drop_constraint(
"fk_users_manager_id",
"users",
type_="foreignkey",
)

# Then remove the column.
op.drop_column(
"users",
"manager_id",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.schemas.auth import LoginRequest, ProfileUpdate, TokenResponse, UserOut
from app.services.auth_service import AuthService

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
service = AuthService(db)
user = service.authenticate(payload.email, payload.password)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
token = service.issue_token(user)
return TokenResponse(access_token=token, role=user.role.value, user_id=user.id)


@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)) -> User:
return current_user


@router.put("/me", response_model=UserOut)
def update_me(
payload: ProfileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> User:
try:
return AuthService(db).update_profile(current_user, payload)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
Loading