diff --git a/migrations/versions/a1b2c3d4e5f6_add_report_log_table.py b/migrations/versions/a1b2c3d4e5f6_add_report_log_table.py new file mode 100644 index 00000000..89f549a9 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_add_report_log_table.py @@ -0,0 +1,50 @@ +"""Add Report Log Table + +Revision ID: a1b2c3d4e5f6 +Revises: a62e70d564b3 +Create Date: 2024-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'a62e70d564b3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('report_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.Column('report_id', sa.Integer(), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=True), + sa.Column('assignment_id', sa.Integer(), nullable=True), + sa.Column('event_type', sa.String(length=255), nullable=False), + sa.Column('field', sa.String(length=255), nullable=True), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('client_timestamp', sa.String(length=255), nullable=True), + sa.Column('client_timezone', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['assignment_id'], ['assignment.id'], ), + sa.ForeignKeyConstraint(['course_id'], ['course.id'], ), + sa.ForeignKeyConstraint(['report_id'], ['report.id'], ), + sa.ForeignKeyConstraint(['subject_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('report_log_report_index', 'report_log', ['report_id'], unique=False) + op.create_index('report_log_subject_index', 'report_log', ['subject_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('report_log_subject_index', table_name='report_log') + op.drop_index('report_log_report_index', table_name='report_log') + op.drop_table('report_log') + # ### end Alembic commands ### \ No newline at end of file diff --git a/models/assignment.py b/models/assignment.py index fc1cd68f..082cc133 100644 --- a/models/assignment.py +++ b/models/assignment.py @@ -86,6 +86,7 @@ class Assignment(EnhancedBase): sample_submissions: Mapped[list["SampleSubmission"]] = db.relationship(back_populates="assignment") submission_logs: Mapped[list["SubmissionLog"]] = db.relationship(back_populates="assignment") assignment_logs: Mapped[list["AssignmentLog"]] = db.relationship(back_populates="assignment") + report_logs: Mapped[list["ReportLog"]] = db.relationship(back_populates="assignment") submissions: Mapped[list["Submission"]] = db.relationship(back_populates="assignment") memberships: Mapped[list["AssignmentGroupMembership"]] = db.relationship(back_populates="assignment") reports: Mapped[list["Report"]] = db.relationship(back_populates="assignment") diff --git a/models/course.py b/models/course.py index dab137a6..b09473e1 100644 --- a/models/course.py +++ b/models/course.py @@ -50,6 +50,7 @@ class Course(Base): role_logs: Mapped[list["RoleLog"]] = db.relationship(back_populates="course") assignment_logs: Mapped[list["AssignmentLog"]] = db.relationship(back_populates="course") submission_logs: Mapped[list["SubmissionLog"]] = db.relationship(back_populates="course") + report_logs: Mapped[list["ReportLog"]] = db.relationship(back_populates="course") submissions: Mapped[list["Submission"]] = db.relationship(back_populates="course") invites: Mapped[list["Invite"]] = db.relationship(back_populates="course") reports: Mapped[list["Report"]] = db.relationship(back_populates="course") diff --git a/models/enums/logs.py b/models/enums/logs.py index 9ef254e9..acf15b02 100644 --- a/models/enums/logs.py +++ b/models/enums/logs.py @@ -48,4 +48,17 @@ class SubmissionLogEvent(StrEnum): TRANSFER = "transfer" CANVAS = "canvas" EXTEND_TIME = "extend_time" - START_TIMER = "start_timer" \ No newline at end of file + START_TIMER = "start_timer" + + +class ReportLogEvent(StrEnum): + CREATE = "create" + DELETE = "delete" + START = "start" + PROGRESS = "progress" + FINISH = "finish" + ERROR = "error" + VIEW = "view" + DOWNLOAD = "download" + SHARE = "share" + EDIT = "edit" \ No newline at end of file diff --git a/models/log_tables/__init__.py b/models/log_tables/__init__.py index 5e632c20..f7c350c8 100644 --- a/models/log_tables/__init__.py +++ b/models/log_tables/__init__.py @@ -8,3 +8,4 @@ from models.log_tables.role_log import RoleLog from models.log_tables.assignment_log import AssignmentLog from models.log_tables.submission_log import SubmissionLog +from models.log_tables.report_log import ReportLog diff --git a/models/log_tables/report_log.py b/models/log_tables/report_log.py new file mode 100644 index 00000000..7eebbd1c --- /dev/null +++ b/models/log_tables/report_log.py @@ -0,0 +1,68 @@ +import logging +from collections import OrderedDict +import time +import json +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Column, String, Integer, ForeignKey, Text, func, JSON, Index, and_, Enum + +import models +from models.generics.models import db, ma +from models.generics.base import Base +from common.dates import datetime_to_string, string_to_datetime +from models.enums import ReportLogEvent +from common.databases import get_enum_values +import models + + +class ReportLog(Base): + __tablename__ = "report_log" + # Identification + report_id: Mapped[int] = mapped_column(Integer(), ForeignKey('report.id')) + subject_id: Mapped[int] = mapped_column(Integer(), ForeignKey('user.id')) + # Optional context - may be derived from report + course_id: Mapped[Optional[int]] = mapped_column(Integer(), ForeignKey('course.id'), nullable=True) + assignment_id: Mapped[Optional[int]] = mapped_column(Integer(), ForeignKey('assignment.id'), nullable=True) + # Actual event data + event_type: Mapped[ReportLogEvent] = mapped_column(Enum(ReportLogEvent, values_callable=get_enum_values), nullable=False) + # For events that need additional data + field: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + value: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) + # When the client initiated this action + client_timestamp: Mapped[Optional[str]] = mapped_column(String(255), default="", nullable=True) + client_timezone: Mapped[Optional[str]] = mapped_column(String(255), default="", nullable=True) + + report: Mapped["Report"] = db.relationship(back_populates="report_logs") + subject: Mapped["User"] = db.relationship(back_populates="report_logs") + course: Mapped[Optional["Course"]] = db.relationship(back_populates="report_logs") + assignment: Mapped[Optional["Assignment"]] = db.relationship(back_populates="report_logs") + + __table_args__ = (Index('report_log_report_index', "report_id"), + Index('report_log_subject_index', "subject_id")) + + @staticmethod + def new(report_id: int, subject_id: int, event_type: ReportLogEvent, + course_id: int = None, assignment_id: int = None, + field: str = None, value: str = None, + client_timestamp: str = None, client_timezone: str = None): + # Validate the event + if not isinstance(event_type, ReportLogEvent): + raise ValueError(f"Invalid event type: {event_type}") + if event_type == ReportLogEvent.EDIT and (field is None or value is None): + raise ValueError("Field and value must be provided for edit events") + if event_type == ReportLogEvent.EDIT and not hasattr(models.Report, field): + raise ValueError(f"Unknown field name for Report: {field}") + # Create the log + log = ReportLog(report_id=report_id, subject_id=subject_id, + course_id=course_id, assignment_id=assignment_id, + event_type=event_type, + field=field, value=value, + client_timestamp=client_timestamp, client_timezone=client_timezone) + db.session.add(log) + db.session.commit() + return log + + def __str__(self): + return f'' \ No newline at end of file diff --git a/models/report.py b/models/report.py index 7e4d292e..ece696b2 100644 --- a/models/report.py +++ b/models/report.py @@ -78,6 +78,7 @@ class Report(Base): assignment: Mapped["Assignment"] = db.relationship(back_populates="reports") owner: Mapped["User"] = db.relationship(back_populates="reports") course: Mapped["Course"] = db.relationship(back_populates="reports") + report_logs: Mapped[list["ReportLog"]] = db.relationship(back_populates="report") def encode_json(self): return { diff --git a/models/user.py b/models/user.py index 8d02415d..5632d379 100644 --- a/models/user.py +++ b/models/user.py @@ -52,6 +52,7 @@ class User(Base, UserMixin): foreign_keys="RoleLog.authorizer_id") assignment_logs: Mapped[list["AssignmentLog"]] = db.relationship(back_populates="subject") submission_logs: Mapped[list["SubmissionLog"]] = db.relationship(back_populates="subject") + report_logs: Mapped[list["ReportLog"]] = db.relationship(back_populates="subject") reviews: Mapped[list["Review"]] = db.relationship(back_populates="author") submissions: Mapped[list["Submission"]] = db.relationship(back_populates="user") reports: Mapped[list["Report"]] = db.relationship(back_populates="owner")