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: 4 additions & 2 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extend-ignore =
# # not required or shadowed by other plugins
D I FI TC Q U101 S101 WPS118 WPS400
# black
WPS220 WPS317 WPS318 WPS348 WPS352 E501 C812 C815 C816 C819 E203
WPS220 WPS317 WPS318 WPS326 WPS348 WPS352 E501 C812 C815 C816 C819 E203
# mypy (for __init__)
WPS410 WPS412
# sqlalchemy needs `id`
Expand All @@ -28,7 +28,7 @@ extend-ignore =
# # weird
PIE803 C101 FNE007 FNE008 N812 ANN101 ANN102 PT004 WPS110 WPS111 WPS114 WPS338 WPS407 WPS414 WPS440 VNE001 VNE002 CM001
# too many
WPS200 WPS201 WPS202 WPS203 WPS204 WPS210 WPS211 WPS212 WPS213 WPS214 WPS217 WPS218 WPS221 WPS224 WPS230 WPS231 WPS234 WPS235 WPS238
WPS200 WPS201 WPS202 WPS203 WPS204 WPS210 WPS211 WPS212 WPS213 WPS214 WPS217 WPS218 WPS221 WPS222 WPS224 WPS230 WPS231 WPS234 WPS235 WPS238
# "vague" imports
WPS347

Expand All @@ -38,6 +38,8 @@ extend-ignore =
U100
# fails to understand enums
WPS115
# broken for longer statements (also shadowed by the formatter)
WPS361
# fails to understand overloading
WPS428
# fails to understand pipe-unions
Expand Down
240 changes: 240 additions & 0 deletions alembic/versions/057_classroom_events_2_0.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""classroom_events_2_0

Revision ID: 057
Revises: 056
Create Date: 2026-04-28 01:01:43.574455

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import BIT

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "057"
down_revision: Union[str, None] = "056"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("scheduler_events", schema="xi_back_2")
sa.Enum(name="eventkind").drop(bind=op.get_bind())
op.create_table(
"events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=1000), nullable=True),
sa.Column("kind", sa.Enum("CLASSROOM", name="eventkind"), nullable=False),
sa.Column("classroom_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events")),
schema="xi_back_2",
)
op.create_table(
"repetition_modes",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column(
"kind", sa.Enum("DAILY", "WEEKLY", name="repetitionkind"), nullable=False
),
sa.Column(
"starts_at",
postgresql.TIMESTAMP(timezone=True, precision=0),
nullable=False,
),
sa.Column(
"ends_at", postgresql.TIMESTAMP(timezone=True, precision=0), nullable=False
),
sa.Column("is_finite", sa.Boolean(), nullable=True),
sa.Column(
"weekly_starting_bitmask",
BIT(length=7),
nullable=True,
),
sa.Column(
"weekly_combined_bitmask",
BIT(length=7),
nullable=True,
),
sa.ForeignKeyConstraint(
["event_id"],
["xi_back_2.events.id"],
name=op.f("fk_repetition_modes_event_id_events"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_repetition_modes")),
schema="xi_back_2",
)
op.create_index(
"index_repetition_modes_kind_and_interval",
"repetition_modes",
["kind", "starts_at", "ends_at"],
unique=False,
schema="xi_back_2",
)
op.create_index(
op.f("ix_xi_back_2_repetition_modes_event_id"),
"repetition_modes",
["event_id"],
unique=False,
schema="xi_back_2",
)
op.create_table(
"event_instances",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"kind",
sa.Enum("SOLE", "REPEATED", name="eventinstancekind"),
nullable=False,
),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"starts_at", postgresql.TIMESTAMP(timezone=True, precision=0), nullable=True
),
sa.Column(
"ends_at", postgresql.TIMESTAMP(timezone=True, precision=0), nullable=True
),
sa.Column("repetition_mode_id", sa.Uuid(), nullable=True),
sa.Column("instance_index", sa.Integer(), nullable=True),
sa.Column(
"starts_at_override",
postgresql.TIMESTAMP(timezone=True, precision=0),
nullable=True,
),
sa.Column(
"ends_at_override",
postgresql.TIMESTAMP(timezone=True, precision=0),
nullable=True,
),
sa.Column("name_override", sa.String(length=100), nullable=True),
sa.Column("description_override", sa.String(length=1000), nullable=True),
sa.ForeignKeyConstraint(
["event_id"],
["xi_back_2.events.id"],
name=op.f("fk_event_instances_event_id_events"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["repetition_mode_id"],
["xi_back_2.repetition_modes.id"],
name=op.f("fk_event_instances_repetition_mode_id_repetition_modes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_event_instances")),
schema="xi_back_2",
)
op.create_index(
"index_repeated_event_instance_interval_override",
"event_instances",
["starts_at_override", "ends_at_override"],
unique=False,
schema="xi_back_2",
postgresql_where=sa.text(
"kind = 'REPEATED' AND starts_at_override IS NOT NULL AND ends_at_override IS NOT NULL"
),
)
op.create_index(
"index_repeated_event_instances_ids",
"event_instances",
["repetition_mode_id", "instance_index"],
unique=False,
schema="xi_back_2",
postgresql_where=sa.text("kind = 'REPEATED'"),
)
op.create_index(
"index_sole_event_instance_interval",
"event_instances",
["starts_at", "ends_at"],
unique=False,
schema="xi_back_2",
postgresql_where=sa.text("kind = 'SOLE'"),
)
op.create_index(
"unique_index_sole_event_instances_event_id",
"event_instances",
["event_id"],
unique=True,
schema="xi_back_2",
postgresql_where=sa.text("kind = 'SOLE'"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"unique_index_sole_event_instances_event_id",
table_name="event_instances",
schema="xi_back_2",
postgresql_where=sa.text("kind = 'SOLE'"),
)
op.drop_index(
"index_sole_event_instance_interval",
table_name="event_instances",
schema="xi_back_2",
postgresql_where=sa.text("kind = 'SOLE'"),
)
op.drop_index(
"index_repeated_event_instances_ids",
table_name="event_instances",
schema="xi_back_2",
postgresql_where=sa.text("kind = 'REPEATED'"),
)
op.drop_index(
"index_repeated_event_instance_interval_override",
table_name="event_instances",
schema="xi_back_2",
postgresql_where=sa.text(
"kind = 'REPEATED' AND starts_at_override IS NOT NULL AND ends_at_override IS NOT NULL"
),
)
op.drop_table("event_instances", schema="xi_back_2")
op.drop_index(
op.f("ix_xi_back_2_repetition_modes_event_id"),
table_name="repetition_modes",
schema="xi_back_2",
)
op.drop_index(
"index_repetition_modes_kind_and_interval",
table_name="repetition_modes",
schema="xi_back_2",
)
op.drop_table("repetition_modes", schema="xi_back_2")
op.drop_table("events", schema="xi_back_2")
sa.Enum(name="eventkind").drop(bind=op.get_bind())
op.create_table(
"scheduler_events",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"starts_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=False,
),
sa.Column(
"ends_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=False,
),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column(
"description", sa.VARCHAR(length=1000), autoincrement=False, nullable=True
),
sa.Column(
"kind",
postgresql.ENUM("CLASSROOM", name="eventkind"),
autoincrement=False,
nullable=False,
),
sa.Column("classroom_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_scheduler_events"),
schema="xi_back_2",
)
# ### end Alembic commands ###
48 changes: 46 additions & 2 deletions app/classrooms/routes/classrooms_int.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
from collections.abc import Sequence
from typing import assert_never
from typing import Annotated, assert_never

from fastapi import Path
from sqlalchemy import or_, select

from app.classrooms.dependencies.classrooms_dep import ClassroomByID
from app.classrooms.models.classrooms_db import GroupClassroom, IndividualClassroom
from app.classrooms.models.classrooms_db import (
Classroom,
GroupClassroom,
IndividualClassroom,
)
from app.classrooms.models.enrollments_db import Enrollment
from app.common.fastapi_ext import APIRouterExt
from app.common.sqlalchemy_ext import db

router = APIRouterExt(tags=["classrooms internal"])

Expand All @@ -23,3 +31,39 @@ async def list_classroom_student_ids(classroom: ClassroomByID) -> Sequence[int]:
)
case _:
assert_never(classroom)


@router.get(
path="/tutors/{tutor_id}/classroom-ids/",
summary="List all classroom ids for a tutor by id",
)
async def list_tutor_classroom_ids(
tutor_id: Annotated[int, Path()],
) -> list[int]:
return await db.get_all_with_assumed_limit(
select(Classroom.id)
.filter_by(tutor_id=tutor_id)
.order_by(Classroom.created_at.desc()),
limit=100,
)


@router.get(
path="/students/{student_id}/classroom-ids/",
summary="List all classroom ids for a student by id",
)
async def list_student_classroom_ids(
student_id: Annotated[int, Path()],
) -> list[int]:
return await db.get_all_with_assumed_limit(
select(Classroom.id)
.join(Enrollment, isouter=True)
.filter(
or_(
IndividualClassroom.student_id == student_id,
Enrollment.student_id == student_id,
)
)
.order_by(Classroom.created_at.desc()),
limit=100,
)
12 changes: 12 additions & 0 deletions app/common/bridges/classrooms_bdg.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ async def list_classroom_student_ids(self, classroom_id: int) -> Response:
return await self.client.get(
f"/classrooms/{classroom_id}/students/",
)

@validate_external_json_response(TypeAdapter(list[int]))
async def list_tutor_classroom_ids(self, tutor_id: int) -> Response:
return await self.client.get(
f"/tutors/{tutor_id}/classroom-ids/",
)

@validate_external_json_response(TypeAdapter(list[int]))
async def list_student_classroom_ids(self, student_id: int) -> Response:
return await self.client.get(
f"/students/{student_id}/classroom-ids/",
)
16 changes: 16 additions & 0 deletions app/common/sqlalchemy_ext.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import logging
import sys
from collections.abc import Iterable, Sequence
from contextvars import ContextVar
Expand Down Expand Up @@ -54,6 +55,21 @@ async def get_count(self, stmt: Select[tuple[int]]) -> int:
async def get_all(self, stmt: Select[Any] | ReturningInsert[Any]) -> Sequence[Any]:
return (await self.session.execute(stmt)).scalars().all()

async def get_all_with_assumed_limit(
self,
stmt: Select[Any],
limit: int,
) -> list[Any]:
result = list(await self.get_all(stmt.limit(limit)))

if len(result) == limit:
logging.warning(
f"Reached the limit of {limit} in one query",
extra={"stmt": str(stmt)},
)

return result

async def get_paginated(
self, stmt: Select[Any], offset: int, limit: int
) -> Sequence[Any]:
Expand Down
6 changes: 6 additions & 0 deletions app/common/utils/bitwise.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def bitwise_cyclic_shift_left(value: int, size: int, rotations: int = 1) -> int:
return ((value << rotations) % (1 << size)) | (value >> (size - rotations))


def bitwise_cyclic_shift_right(value: int, size: int, rotations: int = 1) -> int:
return ((1 << size) - 1) & (value >> rotations | value << (size - rotations))
Loading
Loading