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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ APP_ENV=production alembic upgrade head

- `upgrade head` = run migrations + bump version.
- `stamp head` = bump version only, no migrations executed.
- `alembic downgrade -1` = undo the latest migration

## 6. Course data
- see scraper readme instructions on `export_soc`. Edit Railway cron job at [this link](https://railway.com/project/065650eb-f1b3-4b72-9bdc-e0d0a9457205?environmentId=5b57ba27-25e6-44e5-91b8-6b602ae891f3
Expand Down
203 changes: 185 additions & 18 deletions app/api/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
from app.models.recurrence_rule import add_recurrence_rule
from app.models.event_occurrence import populate_event_occurrences, regenerate_event_occurrences_by_event_ids, save_event_occurrence
from app.models.category import category_to_dict, get_category_by_id
from app.models.models import CalendarSource, Event, RecurrenceRule, UserSavedEvent, Organization, EventOccurrence, EventTag, Category, Tag
from app.models.models import Academic, CalendarSource, Career, Club, Event, RecurrenceRule, UserSavedEvent, Organization, EventOccurrence, EventTag, Category, Tag, RecurrenceExdate, RecurrenceRdate, EventOverride, RecurrenceOverride
import pprint
from datetime import datetime, timezone
from sqlalchemy import cast, Date, or_
from sqlalchemy import cast, Date, or_, delete, select

from app.services.ical import import_ical_feed_using_helpers
from app.services.ical import delete_events_for_calendar_source, import_ical_feed_using_helpers
from app.errors.ical import ICalFetchError
from app.models.calendar_source import create_calendar_source

Expand Down Expand Up @@ -170,21 +170,6 @@ def read_gcal_link():
if not user:
return jsonify({"error": "User not found"}), 404

ics_message = import_ical_feed_using_helpers(
db_session=db,
ical_text_or_url=gcal_link,
org_id=org_id,
category_id=category_id,
semester=semester,
default_event_type=event_type,
user_id=user.id,
source_url=gcal_link
)
print(ics_message)
# Handle parse-level failure
if ics_message.get("success") is False:
return jsonify(ics_message), 400

# Ensure CalendarSource exists (category ⟶ many sources),
# store the gcal link if it is not already stored
calendar_source = (
Expand Down Expand Up @@ -213,6 +198,25 @@ def read_gcal_link():
calendar_source.notes = notes
if event_type is not None:
calendar_source.default_event_type = event_type
if calendar_source.active is False:
calendar_source.active = True
db.flush()

ics_message = import_ical_feed_using_helpers(
db_session=db,
ical_text_or_url=gcal_link,
org_id=org_id,
category_id=category_id,
semester=semester,
default_event_type=event_type,
user_id=user.id,
source_url=gcal_link,
calendar_source_id=calendar_source.id
)
print(ics_message)
# Handle parse-level failure
if ics_message.get("success") is False:
return jsonify(ics_message), 400

db.commit() # Only commit if all succeeded
return jsonify({
Expand Down Expand Up @@ -240,6 +244,40 @@ def read_gcal_link():
"message": str(e),
}), 500

@events_bp.route("/delete_events_and_deactivate_calendar", methods=["DELETE"])
def delete_events_and_deactivate_calendar():
"""Deletes all events associated with a calendar source ID and deactivates it"""
db = g.db
try:
data = request.get_json()
calendar_source_id = data.get("calendar_source_id")

if not calendar_source_id:
return jsonify({"error": "Missing calendar_source_id"}), 400

deleted_event_ids = delete_events_for_calendar_source(
db=db,
calendar_source_id=calendar_source_id,
)

db.commit()

return jsonify({
"status": "ok",
"calendar_source_id": calendar_source_id,
"deleted_events": len(deleted_event_ids),
"event_ids": deleted_event_ids,
}), 200

except ValueError as e:
return jsonify({"error": str(e)}), 404

except Exception as e:
import traceback
print("❌ Exception:", traceback.format_exc())
return jsonify({"error": str(e)}), 500


# @events_bp.route("/generate_more_occurrences", methods=["POST"])
# def generate_more_occurrences():
# db = g.db
Expand Down Expand Up @@ -353,6 +391,8 @@ def regenerate_occurrences_by_events():

regenerated, skipped = regenerate_event_occurrences_by_event_ids(db, event_ids)

print("Before commit, occurrences count:", db.query(EventOccurrence).count())

db.commit()

return jsonify({
Expand Down Expand Up @@ -489,6 +529,133 @@ def get_all_events():
print("❌ Exception:", traceback.format_exc())
return jsonify({"error": str(e)}), 500

@events_bp.route("/batch_delete_events_by_params", methods=["DELETE"])
def batch_delete_events_by_params():
"""
Deletes Events AND all dependent rows:
- EventOccurrence
- RecurrenceRule (+ overrides, rdates, exdates)
- EventTag
- Academic / Career / Club
- UserSavedEvent

Parameters can include semester, org_id, category_id, event_type, source_url.
Note that SOC events are identified by source_url: 'https://enr-apps.as.cmu.edu/open/SOC/SOCServlet/completeSchedule'
"""
db = g.db
try:
data = request.get_json()

semester = data.get("semester")
org_id = data.get("org_id")
category_id = data.get("category_id")
event_type = data.get("event_type")
source_url = data.get("source_url")

if not any([semester, org_id, category_id, event_type, source_url]):
return jsonify({"error": "At least one filter must be provided"}), 400

# --------------------------------------------------
# 1. Build event ID subquery
# --------------------------------------------------
event_ids = select(Event.id)

if semester:
event_ids = event_ids.where(Event.semester == semester)
if org_id:
event_ids = event_ids.where(Event.org_id == org_id)
if category_id:
event_ids = event_ids.where(Event.category_id == category_id)
if event_type:
event_ids = event_ids.where(Event.event_type == event_type)
if source_url:
event_ids = event_ids.where(Event.source_url == source_url)

# Materialize once (for counts + reuse)
event_id_list = db.execute(event_ids).scalars().all()
if not event_id_list:
return jsonify({"status": "no events matched"}), 200

event_id_subq = select(Event.id).where(Event.id.in_(event_id_list))

# --------------------------------------------------
# 2. Delete Event-level children
# --------------------------------------------------
print(f"Deleting dependent rows for {len(event_id_list)} events")
db.execute(
delete(EventOccurrence).where(EventOccurrence.event_id.in_(event_id_subq))
)
db.execute(
delete(EventTag).where(EventTag.event_id.in_(event_id_subq))
)
db.execute(
delete(UserSavedEvent).where(UserSavedEvent.event_id.in_(event_id_subq))
)
db.execute(
delete(Academic).where(Academic.event_id.in_(event_id_subq))
)
db.execute(
delete(Career).where(Career.event_id.in_(event_id_subq))
)
db.execute(
delete(Club).where(Club.event_id.in_(event_id_subq))
)
# --------------------------------------------------
# 3. Handle recurrence hierarchy
# --------------------------------------------------
rrule_ids = select(RecurrenceRule.id).where(
RecurrenceRule.event_id.in_(event_id_subq)
)
db.execute(
delete(RecurrenceExdate).where(
RecurrenceExdate.rrule_id.in_(rrule_ids)
)
)

db.execute(
delete(RecurrenceRdate).where(
RecurrenceRdate.rrule_id.in_(rrule_ids)
)
)

db.execute(
delete(EventOverride).where(
EventOverride.rrule_id.in_(rrule_ids)
)
)

db.execute(
delete(RecurrenceOverride).where(
RecurrenceOverride.rrule_id.in_(rrule_ids)
)
)

db.execute(
delete(RecurrenceRule).where(
RecurrenceRule.id.in_(rrule_ids)
)
)

# --------------------------------------------------
# 4. Delete events
# --------------------------------------------------
db.execute(
delete(Event).where(Event.id.in_(event_id_subq))
)

db.commit()

return jsonify({
"status": "ok",
"deleted_events": len(event_id_list),
"event_ids": event_id_list,
}), 200

except Exception as e:
import traceback
print("❌ Exception:", traceback.format_exc())
return jsonify({"error": str(e)}), 500

@events_bp.route("/<event_id>", methods=["GET"])
def get_specific_events(event_id):
print("🍎🍎🍎🍎", request.url)
Expand Down
Empty file added app/models/calend
Empty file.
37 changes: 36 additions & 1 deletion app/models/calendar_source.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from app.models.models import CalendarSource
from typing import Optional
from sqlalchemy.orm import Session
from datetime import datetime, timezone

def create_calendar_source(
db_session,
Expand All @@ -26,4 +28,37 @@ def create_calendar_source(

db_session.add(calendar_source)
db_session.flush()
return calendar_source
return calendar_source


def deactivate_calendar_source(
db: Session,
calendar_source_id: int,
) -> CalendarSource:
"""
Mark a CalendarSource as inactive.

- Acquires a row-level lock
- Updates active flag and updated_at
- Does NOT commit (caller controls transaction)

Returns:
The updated CalendarSource
"""

calendar_source = (
db.query(CalendarSource)
.filter(CalendarSource.id == calendar_source_id)
.with_for_update()
.one_or_none()
)

if not calendar_source:
raise ValueError("CalendarSource not found")

if calendar_source.active:
calendar_source.active = False
calendar_source.updated_at = datetime.now(timezone.utc)

db.flush()
return calendar_source
10 changes: 7 additions & 3 deletions app/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
import requests
from app.utils.date import infer_semester_from_datetime
from app.utils.date import ensure_aware_datetime, infer_semester_from_datetime
from app.models.models import Event, RecurrenceRule, EventOccurrence, RecurrenceRdate, RecurrenceExdate, EventOverride

### need to check type of start_datetime, end_datetime before using them
Expand All @@ -29,6 +29,9 @@ def save_event(db, org_id: int, category_id: int, title: str, start_datetime: st
Returns:
The created Event object.
"""
start_dt = ensure_aware_datetime(start_datetime)
end_dt = ensure_aware_datetime(end_datetime)

if semester is None:
semester = infer_semester_from_datetime(start_datetime)

Expand All @@ -37,8 +40,8 @@ def save_event(db, org_id: int, category_id: int, title: str, start_datetime: st
category_id=category_id,
title=title,
description=description,
start_datetime=start_datetime,
end_datetime=end_datetime,
start_datetime=start_dt,
end_datetime=end_dt,
is_all_day=is_all_day,
location=location,
semester=semester,
Expand All @@ -50,6 +53,7 @@ def save_event(db, org_id: int, category_id: int, title: str, start_datetime: st
db.flush() # Allocate event.id without committing
return event


def get_event_by_id(db, event_id: int):
"""
Retrieve an event by its ID.
Expand Down
Loading