From 412a8d35b1f7316364f94b6aab726a15af18d62a Mon Sep 17 00:00:00 2001 From: vidit choudhary <153429790+vidit-16@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:14:26 +0530 Subject: [PATCH 1/2] Two-sided handover: the finder and the desk each confirm Handing in an item is a transfer between two people, so both now say so. - The finder can mark their found item "I've handed it in". It goes to the top of the Desk page with a badge, and desk staff get an email. The desk confirms it (held) or says it was not received (back to awaiting, and the finder is told). - When the desk logs an item first, it is held at once so the owner can claim it, and the finder is asked on every page "Was that you?". "That wasn't me" flags it on the Desk page and emails the admin. - Every step goes into the custody log with names and times. Notices use the shared style, so none of them sit on top of the page. --- app.py | 134 ++++++++++++++++++++++++++++++++++++++--- static/style.css | 4 ++ templates/base.html | 19 +++++- templates/desk.html | 41 +++++++++++++ templates/item.html | 9 +++ templates/profile.html | 2 +- tests/test_app.py | 88 +++++++++++++++++++++++++++ 7 files changed, 287 insertions(+), 10 deletions(-) diff --git a/app.py b/app.py index 3f60b55..c553500 100644 --- a/app.py +++ b/app.py @@ -272,6 +272,9 @@ class Item(db.Model): # Where a found item physically is: awaiting drop-off at the security desk, # held there, or released to its owner. Empty for lost reports. custody = db.Column(db.String(20)) + # The finder's answer once the desk logs the item as received: "pending" + # until they confirm it was them, "confirmed", or "disputed". + finder_check = db.Column(db.String(10)) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) claims = db.relationship("Claim", back_populates="item", cascade="all, delete-orphan") events = db.relationship( @@ -411,6 +414,7 @@ class CustodyEvent(db.Model): VALUABLE_CATEGORIES = {"Electronics", "Wallet & ID", "Keys"} CUSTODY_LABELS = { "awaiting": "Awaiting drop-off at the security desk", + "handed": "Handed in, waiting for the desk to confirm", "held": "Held at the security desk", "office": "Moved to the admin office for safekeeping", "released": "Returned to its owner", @@ -529,6 +533,16 @@ def set_place(item, place="", side=""): item.place_side = found["side"] if found["place"] else None +def add_handover_columns(): + """Add item.finder_check to tables that predate the two-sided handover.""" + inspector = db.inspect(db.engine) + if "item" not in inspector.get_table_names(): + return + if "finder_check" not in {column["name"] for column in inspector.get_columns("item")}: + db.session.execute(db.text("ALTER TABLE item ADD COLUMN finder_check VARCHAR(10)")) + db.session.commit() + + def add_place_columns(): """Add item.place to tables that predate the map, and place old reports from their text.""" inspector = db.inspect(db.engine) @@ -554,6 +568,7 @@ def add_place_columns(): backfill_item_owners() add_desk_columns() add_place_columns() + add_handover_columns() if not app.config["S3_BUCKET"]: Path(app.config["UPLOAD_FOLDER"]).mkdir(parents=True, exist_ok=True) @@ -657,11 +672,17 @@ def inject_current_user(): if user and not user.works_desk: invite = next((i for i in StaffInvite.query.filter_by(email=user.email.lower(), responded_at=None) .order_by(StaffInvite.created_at.desc()).limit(3) if i.is_open), None) + handover_checks = [] + if user: + handover_checks = Item.query.filter_by(owner_id=user.id, finder_check="pending").all() + desk_queue = Item.query.filter_by(status="Found", custody="handed").count() if user and user.works_desk else 0 return { "current_user": user, "incoming_claim_count": incoming, "outgoing_claim_count": outgoing, "staff_invite": invite, + "handover_checks": handover_checks, + "desk_queue": desk_queue, } @@ -2287,35 +2308,132 @@ def desk(): if moved: flash(f"{moved} unclaimed valuable item{'s' if moved != 1 else ''} moved to the admin office.", "success") awaiting = Item.query.filter_by(status="Found", custody="awaiting").order_by(Item.created_at).all() + handed = Item.query.filter_by(status="Found", custody="handed").order_by(Item.created_at).all() + disputed = Item.query.filter_by(finder_check="disputed").order_by(Item.created_at.desc()).all() held = (Item.query.filter(Item.status == "Found", Item.custody.in_(IN_STORAGE)) .order_by(Item.created_at).all()) pending = Claim.query.filter_by(status="Pending").order_by(Claim.created_at).all() approved = Claim.query.filter_by(status="Approved").order_by(Claim.decided_at).all() - return render_template("desk.html", awaiting=awaiting, held=held, pending=pending, approved=approved) + return render_template("desk.html", awaiting=awaiting, handed=handed, disputed=disputed, held=held, + pending=pending, approved=approved) @app.post("/desk/item//received") @staff_required def desk_receive(item_id): item = db.get_or_404(Item, item_id) - if item.status != "Found" or item.custody != "awaiting": - flash("That item is not awaiting drop-off.", "error") + if item.status != "Found" or item.custody not in {"awaiting", "handed"}: + flash("That item is not waiting to be received.", "error") return redirect(url_for("desk")) + staff = get_current_user() + finder = db.session.get(User, item.owner_id) if item.owner_id else None + finder_said_so = item.custody == "handed" item.custody = "held" - log_custody(item, "Received at the security desk", get_current_user()) + if finder_said_so: + item.finder_check = "confirmed" + log_custody(item, "Received at the security desk, confirming the finder's hand-in", staff) + else: + # The item is held at once, so the owner can claim it; the finder is + # asked to confirm it was them, but nothing waits on their answer. + item.finder_check = "pending" if finder and finder.id != staff.id else None + log_custody(item, "Received at the security desk", staff) + db.session.commit() + if finder and finder.id != staff.id: + ask = "" if finder_said_so else ( + "\n\nIf that wasn't you, open ClassFind and tell us: there is a notice on every page " + f"until you answer.\n{url_for('item_detail', item_id=item.id, _external=True)}") + send_email( + finder.email, + f"The desk received {item.title}", + f"Hi {finder.name},\n\nThe security desk logged \"{item.title}\" as received from you. " + f"We'll take it from here. Thank you for returning it.{ask}", + ) + flash(f"{item.title} is now held at the desk.", "success") + return redirect(url_for("desk")) + + +@app.post("/desk/item//not-received") +@staff_required +def desk_not_received(item_id): + """The finder said they handed it in, but the desk does not have it.""" + item = db.get_or_404(Item, item_id) + if item.status != "Found" or item.custody != "handed": + flash("That item is not waiting for the desk to confirm.", "error") + return redirect(url_for("desk")) + item.custody = "awaiting" + log_custody(item, "The desk has not received it; back to awaiting drop-off", get_current_user()) db.session.commit() finder = db.session.get(User, item.owner_id) if item.owner_id else None if finder: send_email( finder.email, - f"Thanks for handing in {item.title}", - f"Hi {finder.name},\n\nThe security desk has received \"{item.title}\". " - "We'll take it from here. Thank you for returning it.", + f"The desk hasn't received {item.title}", + f"Hi {finder.name},\n\nYou marked \"{item.title}\" as handed in, but the security desk " + "doesn't have it. If you still have it, please bring it to the desk in the Main Block.\n" + f"{url_for('item_detail', item_id=item.id, _external=True)}", ) - flash(f"{item.title} is now held at the desk.", "success") + flash(f"{item.title} is back to awaiting drop-off, and the finder has been told.", "success") return redirect(url_for("desk")) +@app.post("/item//handed-in") +@login_required +def mark_handed_in(item_id): + """The finder says they handed the item in; the desk is asked to confirm.""" + item = db.get_or_404(Item, item_id) + user = get_current_user() + if not owns_item(user, item) or item.status != "Found" or item.custody != "awaiting": + flash("Only the finder can mark an item awaiting drop-off as handed in.", "error") + return redirect(url_for("item_detail", item_id=item.id)) + item.custody = "handed" + log_custody(item, "Finder says they handed it in at the security desk", user) + db.session.commit() + for staff in User.query.filter(or_(User.is_staff.is_(True), User.is_admin.is_(True))).all(): + send_email( + staff.email, + f"Confirm you received {item.title}", + f"{user.name} says they handed \"{item.title}\" in at the security desk. " + "Please confirm on the Desk page whether you have it.\n" + f"{url_for('desk', _external=True)}", + ) + flash("Thanks! The desk will confirm once they've checked.", "success") + return redirect(url_for("item_detail", item_id=item.id)) + + +@app.post("/item//handover-check") +@login_required +def answer_handover_check(item_id): + """The finder confirms the desk's record, or says it wasn't them.""" + item = db.get_or_404(Item, item_id) + user = get_current_user() + if item.owner_id != user.id or item.finder_check != "pending": + flash("There is nothing to confirm for that item.", "error") + return redirect(url_for("profile")) + if request.form.get("answer") == "yes": + item.finder_check = "confirmed" + log_custody(item, "Finder confirmed handing it in", user) + flash("Thanks for confirming.", "success") + else: + item.finder_check = "disputed" + log_custody(item, "Finder says they did not hand this in; flagged for the admin", user) + admin = admin_email() + if admin: + send_email( + admin, + f"Check the handover of {item.title}", + f"{user.name} says they did not hand in \"{item.title}\", which the desk logged as " + "received from them. Please check it.\n" + f"{url_for('item_detail', item_id=item.id, _external=True)}", + ) + flash("Thanks for telling us. The admin will check this handover.", "success") + db.session.commit() + # Back to the page the notice was on, but only ever within this site. + target = request.form.get("next", "") + if not target.startswith("/") or target.startswith("//"): + target = url_for("item_detail", item_id=item.id) + return redirect(target) + + @app.post("/claims//reissue") @staff_required def reissue_code(claim_id): diff --git a/static/style.css b/static/style.css index 1e37b38..3147acb 100644 --- a/static/style.css +++ b/static/style.css @@ -286,3 +286,7 @@ body::after{content:"";position:fixed;z-index:-1;width:420px;height:420px;right: .notice.warn{background:#fbf1e3;border-color:#ecd3ac} .flash-stack{position:relative;z-index:1} .invite-question{margin:0 0 18px;font-size:16px;line-height:1.6}.invite-question strong{display:block;font:600 20px "Space Grotesk",sans-serif;margin-bottom:4px} +.custody-handed{background:#fbf1e3;color:#8a5a12} +.notice .secondary-btn.compact{padding:10px 14px} +form.notice{margin-top:4px} +.desk-overview{grid-template-columns:repeat(5,minmax(0,1fr))}@media(max-width:900px){.desk-overview{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/templates/base.html b/templates/base.html index 1ebe5e6..720a298 100644 --- a/templates/base.html +++ b/templates/base.html @@ -32,7 +32,7 @@ Claims{% if incoming_claim_count or outgoing_claim_count %} {{ incoming_claim_count + outgoing_claim_count }}{% endif %} My Reports QR Tags - {% if current_user.works_desk %}Desk{% endif %} + {% if current_user.works_desk %}Desk{% if desk_queue %} {{ desk_queue }}{% endif %}{% endif %} {% if current_user.is_admin %}Admin{% endif %}
{% else %} @@ -55,6 +55,23 @@ See the invitation {% endif %} + {% for check in handover_checks %} +
+

The security desk logged your {{ check.title }} as received from you. Was that you?

+
+ + + + +
+
+ {% endfor %} + {% if desk_queue and request.endpoint != "desk" %} +
+

{{ desk_queue }} item{{ "s" if desk_queue != 1 }} handed in by finders {{ "are" if desk_queue != 1 else "is" }} waiting for the desk to confirm.

+ Open the desk +
+ {% endif %} {% block content %}{% endblock %}
ClassFind · BIT campus lost and foundFind it. Report it. Return it.
diff --git a/templates/desk.html b/templates/desk.html index ac2eea2..b712e1a 100644 --- a/templates/desk.html +++ b/templates/desk.html @@ -11,6 +11,7 @@

Every found item, accounted for.

+
To confirm{{ handed|length }}Finder says it's handed in
Awaiting drop-off{{ awaiting|length }}Reported, not yet handed in
Held at desk{{ held|length }}In storage now
Claims to review{{ pending|length }}Check the proof
@@ -32,6 +33,46 @@

Enter the owner's collection code

+{% if handed %} +
+

HANDED IN, PLEASE CONFIRM

{{ handed|length }} to check

The finder says these are at the desk
+
+ {% for item in handed %} +
+
+ {{ item.category }} +

{{ item.title }}

+

Handed in by {{ item.reporter_name }} · ⌖ {{ item.location }}

+ Reported {{ item.created_at.strftime("%d %b %Y, %I:%M %p") }} +
+
+
+
+
+
+ {% endfor %} +
+
+{% endif %} + +{% if disputed %} +
+

{{ disputed|length }} handover{{ "s" if disputed|length != 1 }} the finder says {{ "were" if disputed|length != 1 else "was" }} not theirs. Check the custody log and ask at the desk.

+
+ {% for item in disputed %} + + {% endfor %} +
+
+{% endif %} + +

CLAIMS TO REVIEW

{{ pending|length }} waiting

Approve only when the proof matches the item
{% if pending %} diff --git a/templates/item.html b/templates/item.html index 0fef290..e1c5fd1 100644 --- a/templates/item.html +++ b/templates/item.html @@ -20,6 +20,15 @@

{{ item.title }}

{% endif %} + {% if can_manage and item.status == "Found" and item.custody == "awaiting" and item.owner_id == current_user.id %} +
+ +

Have you handed this in at the security desk in the Main Block?

+ +
+ {% elif can_manage and item.custody == "handed" %} +

You marked this as handed in. The desk will confirm once they've checked.

+ {% endif %} {% if can_manage %}
Edit report diff --git a/templates/profile.html b/templates/profile.html index 5ed4ba6..3ba3102 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -20,7 +20,7 @@ {% if items %}
{% for item in items %}
-
{% if item.image_src %}{{ item.title }}{% else %}{{ item.category[:1].upper() }}{% endif %}{{ item.status }}
+
{% if item.image_src %}{{ item.title }}{% else %}{{ item.category[:1].upper() }}{% endif %}{{ item.status }}{% if item.custody in ("awaiting", "handed") %}{{ "Waiting for the desk" if item.custody == "handed" else "Hand it in" }}{% endif %}

{{ item.category }}

{{ item.title }}

{{ item.description }}

⌖ {{ item.location }}{{ item.created_at.strftime("%d %b %Y") }}
{% endfor %}
diff --git a/tests/test_app.py b/tests/test_app.py index 448d1dd..87fe9a6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -424,6 +424,94 @@ def test_found_items_go_through_the_security_desk(self): response = self.post("/desk/handover", data={"code": code}, follow_redirects=True) self.assertIn(b"does not match", response.data) + def test_finder_says_handed_in_and_the_desk_confirms(self): + item_id = self.found_item() + self.register_staff() + self.logout() + self.login("finder@example.com") + page = self.client.get(f"/item/{item_id}").data + self.assertIn(b"handed it in", page) + with mock.patch("app.send_email") as sent: + self.post(f"/item/{item_id}/handed-in", follow_redirects=True) + self.assertIn("desk@example.com", [call.args[0] for call in sent.call_args_list]) + with app.app_context(): + self.assertEqual(db.session.get(Item, item_id).custody, "handed") + self.logout() + self.login("desk@example.com") + home = self.client.get("/").data + self.assertIn(b"waiting for the desk to confirm", home) + self.assertIn(b'Desk 1', home) + self.post(f"/desk/item/{item_id}/received", follow_redirects=True) + with app.app_context(): + item = db.session.get(Item, item_id) + self.assertEqual((item.custody, item.finder_check), ("held", "confirmed")) + self.assertIn("confirming the finder", item.events[-1].action) + self.logout() + self.login("finder@example.com") + # The finder already said so, so they are not asked again. + self.assertNotIn(b"Was that you?", self.client.get("/").data) + + def test_desk_says_not_received_and_the_finder_is_told(self): + item_id = self.found_item() + self.login("finder@example.com") + self.post(f"/item/{item_id}/handed-in") + self.logout() + self.register_staff() + with mock.patch("app.send_email") as sent: + self.post(f"/desk/item/{item_id}/not-received", follow_redirects=True) + self.assertEqual(sent.call_args.args[0], "finder@example.com") + with app.app_context(): + self.assertEqual(db.session.get(Item, item_id).custody, "awaiting") + + def test_desk_receives_first_and_the_finder_confirms_or_disputes(self): + first = self.found_item("Blue bottle") + self.login("finder@example.com") + self.report("Red cap", "Cotton cap", "Clothing", "Gym", "Found") + self.logout() + with app.app_context(): + second = Item.query.filter_by(title="Red cap").one().id + self.register_admin("admin@example.com") + self.post(f"/desk/item/{first}/received") + self.post(f"/desk/item/{second}/received") + with app.app_context(): + # Held at once, so the owner can claim it while the finder is asked. + self.assertEqual(db.session.get(Item, first).custody, "held") + self.assertEqual(db.session.get(Item, first).finder_check, "pending") + self.logout() + self.login("finder@example.com") + home = self.client.get("/").data + self.assertEqual(home.count(b"Was that you?"), 2) + self.post(f"/item/{first}/handover-check", data={"answer": "yes", "next": "/"}) + with mock.patch("app.send_email") as sent: + response = self.post(f"/item/{second}/handover-check", data={"answer": "no", "next": "//evil.example"}) + self.assertEqual(sent.call_args.args[0], "admin@example.com") + self.assertTrue(response.headers["Location"].endswith(f"/item/{second}")) + self.assertNotIn(b"Was that you?", self.client.get("/").data) + with app.app_context(): + self.assertEqual(db.session.get(Item, first).finder_check, "confirmed") + self.assertEqual(db.session.get(Item, second).finder_check, "disputed") + self.logout() + self.login("admin@example.com") + desk = self.client.get("/desk").data + self.assertIn(b"not theirs", desk) + self.assertIn(b"Red cap", desk.split(b"not theirs", 1)[1]) + + def test_only_the_finder_can_mark_an_item_handed_in(self): + item_id = self.found_item() + self.register("Someone", "someone@example.com") + self.post(f"/item/{item_id}/handed-in", follow_redirects=True) + self.post(f"/item/{item_id}/handover-check", data={"answer": "no"}) + with app.app_context(): + item = db.session.get(Item, item_id) + self.assertEqual((item.custody, item.finder_check), ("awaiting", None)) + + def test_the_handover_column_is_added_to_an_older_database(self): + with app.app_context(): + db.session.execute(db.text("ALTER TABLE item DROP COLUMN finder_check")) + db.session.commit() + app_module.add_handover_columns() + self.assertIn("finder_check", {c["name"] for c in db.inspect(db.engine).get_columns("item")}) + def test_handover_waits_for_the_item_to_reach_the_desk(self): item_id = self.found_item() self.register("Owner", "owner@example.com") From 702b8d6e972759a5e4dfce685ff121efe718780a Mon Sep 17 00:00:00 2001 From: vidit choudhary <153429790+vidit-16@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:14:43 +0530 Subject: [PATCH 2/2] Mutation-test the handover confirmations too --- tools/mutate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/mutate.py b/tools/mutate.py index 7b8353c..adde20f 100644 --- a/tools/mutate.py +++ b/tools/mutate.py @@ -30,6 +30,8 @@ ["handover", "code", "collect", "desk", "reissue"]), ("app.py", {"escalate_unclaimed_valuables"}, ["escalat", "valuable", "office"]), ("app.py", {"set_place", "search_filter"}, ["place", "search", "picker"]), + ("app.py", {"desk_receive", "desk_not_received", "mark_handed_in", "answer_handover_check"}, + ["handed", "handover", "finder", "not_received", "desk"]), ] COMPARE_SWAPS = {ast.Lt: ast.LtE, ast.LtE: ast.Lt, ast.Gt: ast.GtE, ast.GtE: ast.Gt,