Skip to content

Commit b5e2d64

Browse files
committed
feat: merge activities
1 parent ab683be commit b5e2d64

File tree

2 files changed

+188
-3
lines changed

2 files changed

+188
-3
lines changed

routers/activities_v2_router.py

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import re
2+
from collections import defaultdict
23
from datetime import datetime
34
from typing import Literal
5+
from bson import ObjectId
46
from fastapi import APIRouter, Depends, HTTPException
5-
from fastapi.responses import JSONResponse, UJSONResponse
7+
from fastapi.responses import JSONResponse
68
from pydantic import BaseModel
79
from database import db
810
from typings.activity_v2 import Activity, ActivityMember
911
from typings.log import inject_log
1012
from typings.user import User as UserV1
11-
from util.object_id import get_current_user
13+
from util.object_id import get_current_user, compulsory_temporary_token
1214
from util.permission import volunteer, volunteer_member
1315
from util.user import get_user_name
1416
from util.validation import validate_activity_name
@@ -424,3 +426,173 @@ async def modify_activity_status_v2(
424426
await log.insert_log()
425427

426428
return JSONResponse({"detail": "Activity status updated successfully"})
429+
430+
431+
class AmalgamationForm(BaseModel):
432+
"""
433+
Form for amalgamating multiple activities into one.
434+
435+
This form is used to specify the activities to be amalgamated, the name and description of the new activity,
436+
437+
:param activities: List of activity IDs to be amalgamated
438+
:param name: Name of the new activity
439+
:param description: Description of the new activity. (optional) If not provided, it will be set to the concatenation of the names of the amalgamated activities.
440+
:param duplicated: How to handle duplicated members. Options are 'max' (keep the maximum count) or 'merge' (sum the counts).
441+
:param proceedPending: Whether to proceed with pending activities. If False, only effective activities will be amalgamated; if true, the final activity will be set to pending.
442+
"""
443+
444+
activities: list[str]
445+
name: str
446+
description: str=''
447+
origin: Literal[
448+
"labor",
449+
"organization",
450+
"tasks",
451+
"occasions",
452+
"import",
453+
"activities",
454+
"practice",
455+
"club",
456+
"prize",
457+
"other",
458+
]
459+
duplicated: Literal["max", "sum"]="max"
460+
proceedPending: bool=False
461+
462+
463+
@router.post("/amalgamation")
464+
async def amalgamate_activities_v2(
465+
form: AmalgamationForm, user=Depends(compulsory_temporary_token), log=Depends(inject_log)
466+
):
467+
"""
468+
Amalgamate multiple activities into one.
469+
470+
The entire process involves the following steps:
471+
1. Check if the user has permission to amalgamate activities.
472+
2. Check **all** of activities are effective. If it includes `refused` activities, raise an error. Otherwise, amalgamate them according to the `proceedPending` parameter.
473+
3. Create a new activity with the amalgamated information. It includes the following logics:
474+
1. **Infer the type.** If all activities are of the same type, use that type. Otherwise, use `hybrid`.
475+
2. **Infer the appointee.** If all activities have the same appointee, use that appointee. Otherwise, set it to the creator of the new activity.
476+
3. The inference of approver is similar to appointee. However, if *any* of the activities has `authority` as the approver, the new activity will also have `authority` as the approver.
477+
4. Process the status.
478+
- If `proceedPending` is True, the new activity will be set to `pending`.
479+
- If `proceedPending` is False, the new activity will be set to `effective`, but it will only include the members from the effective activities.
480+
4. Amalgamate the members of the activities according to the `duplicated` parameter. If `duplicated` is set to `max`, the maximum count of the members will be kept. If `duplicated` is set to `merge`, the counts of the members will be summed up. Please be mind that an activity record can include a person with different modes (if `hybrid`), but can't include a person with the same mode multiple times. If it happens, an error will be raised.
481+
482+
:param form: AmalgamationForm containing the activities to be amalgamated and the new activity information
483+
:param user: Current user
484+
:param log: Logger object
485+
:return: ID of the new amalgamated activity
486+
"""
487+
488+
# Here, we only allow admin and volunteer to amalgamate activities.
489+
await volunteer.validate_create_permission(user, True)
490+
491+
pipeline = {
492+
"_id": {
493+
"$in": [validate_object_id(activity_id) for activity_id in form.activities]
494+
},
495+
"status": {"$in": ["effective", "pending", "refused"]},
496+
}
497+
if not form.proceedPending:
498+
pipeline["status"] = "effective"
499+
500+
# Validate all activities are effective or pending
501+
activities = [
502+
{'_id': str(obj['_id']), **obj}
503+
for obj in await db.zvms_new.get_collection("activities")
504+
.find(pipeline)
505+
.to_list(None)
506+
]
507+
508+
final_status = "effective" if not form.proceedPending else "pending"
509+
510+
if not activities:
511+
raise HTTPException(status_code=404, detail="No activities found")
512+
if any(activity['status'] == "refused" for activity in activities):
513+
raise HTTPException(
514+
status_code=400, detail="Cannot amalgamate refused activities"
515+
)
516+
if not all(activity['status'] in ["effective", "pending"] for activity in activities):
517+
raise HTTPException(
518+
status_code=400, detail="All activities must be effective or pending"
519+
)
520+
if all(activity['status'] == "effective" for activity in activities):
521+
final_status = "effective"
522+
523+
final_appointee = set(activity['appointee'] for activity in activities)
524+
if len(final_appointee) == 1:
525+
final_appointee = final_appointee.pop()
526+
else:
527+
final_appointee = str(user["id"])
528+
final_approver = (
529+
"authority"
530+
if any(activity['approver'] == "authority" for activity in activities)
531+
else str(user["id"])
532+
)
533+
final_type = (
534+
"hybrid"
535+
if any(activity['type'] == "hybrid" for activity in activities)
536+
else (
537+
activities[0]['type']
538+
if all(activity['type'] == activities[0]['type'] for activity in activities)
539+
else "hybrid"
540+
)
541+
)
542+
final_description = form.description or "Merged from " " and ".join(
543+
activity['name'] for activity in activities
544+
) + "\n\nDescriptions:\n" + "\n".join(
545+
activity['description'] for activity in activities
546+
)
547+
new_activity = Activity(
548+
_id=str(ObjectId()),
549+
name=form.name,
550+
description=final_description,
551+
type=final_type,
552+
appointee=final_appointee,
553+
approver=final_approver,
554+
status=final_status,
555+
creator=str(user["id"]),
556+
origin=form.origin,
557+
place="N/A",
558+
date=datetime.now(),
559+
createdAt=datetime.now(),
560+
updatedAt=datetime.now(),
561+
)
562+
new_activity = new_activity.model_dump()
563+
result = await db.zvms_new.get_collection("activities").insert_one(new_activity)
564+
new_id = str(result.inserted_id)
565+
log_text = (
566+
f'User {await get_user_name(user["id"])} amalgamated activities {", ".join(form.activities)} into activity {form.name} at {datetime.now().isoformat()}. The ID of the new activity is {new_id}.'
567+
)
568+
await db.zvms_new.get_collection('activity_members').update_many({
569+
"activity": {"$in": [str(activity['_id']) for activity in activities]}
570+
}, {"$set": {"activity": new_id}})
571+
# Then checkout duplicated members
572+
# Step 1: Group documents by (mode, activity, member)
573+
grouped = defaultdict(list)
574+
for doc in (await db.zvms_new.get_collection("activity_members").find().to_list(None)):
575+
key = (doc["mode"], doc["activity"], doc["member"])
576+
grouped[key].append(doc)
577+
578+
# Step 2: For groups with duplicates, keep one with new duration
579+
for key, docs in grouped.items():
580+
if len(docs) <= 1:
581+
continue
582+
583+
if form.duplicated == "sum":
584+
new_duration = sum(d["duration"] for d in docs)
585+
elif form.duplicated == "max":
586+
new_duration = max(d["duration"] for d in docs)
587+
else:
588+
raise ValueError("Invalid duplicated mode")
589+
590+
# Keep the first doc, delete others
591+
keep_doc = docs[0]
592+
other_ids = [d["_id"] for d in docs[1:]]
593+
db.zvms_new.get_collection('activity_members').delete_many({"_id": {"$in": other_ids}})
594+
db.zvms_new.get_collection('activity_members').update_one({"_id": keep_doc["_id"]}, {"$set": {"duration": new_duration}})
595+
log.with_text(log_text)
596+
db.zvms_new.get_collection('activities').delete_many({'_id': {"$in": [validate_object_id(activity['_id']) for activity in activities]}})
597+
await log.insert_log()
598+
return JSONResponse({'_id': str(result.inserted_id)}, status_code=201)

routers/users_v2_router.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,22 @@ async def get_user_time_v2(user_id: str, user=Depends(get_current_user)):
122122
"""
123123
await validate_read_user_permission(user, user_id, "volunteer")
124124

125+
accepted_activities = (
126+
await db.zvms_new.get_collection("activities")
127+
.find({"status": "effective"})
128+
.to_list(None)
129+
)
130+
accepted_activities = [str(activity["_id"]) for activity in accepted_activities]
131+
125132
collections = (
126133
await db.zvms_new.get_collection("activity_members")
127-
.find({"member": user_id})
134+
.find(
135+
{
136+
"member": user_id,
137+
"status": "effective",
138+
"activity": {"$in": accepted_activities},
139+
}
140+
)
128141
.to_list(None)
129142
)
130143
result = defaultdict(float)

0 commit comments

Comments
 (0)