|
1 | 1 | import re |
| 2 | +from collections import defaultdict |
2 | 3 | from datetime import datetime |
3 | 4 | from typing import Literal |
| 5 | +from bson import ObjectId |
4 | 6 | from fastapi import APIRouter, Depends, HTTPException |
5 | | -from fastapi.responses import JSONResponse, UJSONResponse |
| 7 | +from fastapi.responses import JSONResponse |
6 | 8 | from pydantic import BaseModel |
7 | 9 | from database import db |
8 | 10 | from typings.activity_v2 import Activity, ActivityMember |
9 | 11 | from typings.log import inject_log |
10 | 12 | 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 |
12 | 14 | from util.permission import volunteer, volunteer_member |
13 | 15 | from util.user import get_user_name |
14 | 16 | from util.validation import validate_activity_name |
@@ -424,3 +426,173 @@ async def modify_activity_status_v2( |
424 | 426 | await log.insert_log() |
425 | 427 |
|
426 | 428 | 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) |
0 commit comments