Skip to content
Merged
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
134 changes: 126 additions & 8 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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/<int:item_id>/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/<int:item_id>/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/<int:item_id>/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/<int:item_id>/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/<int:claim_id>/reissue")
@staff_required
def reissue_code(claim_id):
Expand Down
4 changes: 4 additions & 0 deletions static/style.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<a class="{% if request.endpoint == 'claims' %}active{% endif %}" href="{{ url_for('claims') }}">Claims{% if incoming_claim_count or outgoing_claim_count %} <span class="count-badge">{{ incoming_claim_count + outgoing_claim_count }}</span>{% endif %}</a>
<a class="{% if request.endpoint == 'profile' %}active{% endif %}" href="{{ url_for('profile') }}">My Reports</a>
<a class="{% if request.endpoint == 'tags' %}active{% endif %}" href="{{ url_for('tags') }}">QR Tags</a>
{% if current_user.works_desk %}<a class="{% if request.endpoint == 'desk' %}active{% endif %}" href="{{ url_for('desk') }}">Desk</a>{% endif %}
{% if current_user.works_desk %}<a class="{% if request.endpoint == 'desk' %}active{% endif %}" href="{{ url_for('desk') }}">Desk{% if desk_queue %} <span class="count-badge">{{ desk_queue }}</span>{% endif %}</a>{% endif %}
{% if current_user.is_admin %}<a class="{% if request.endpoint == 'admin_dashboard' %}active{% endif %}" href="{{ url_for('admin_dashboard') }}">Admin</a>{% endif %}
<form method="post" action="{{ url_for('logout') }}" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="nav-logout" type="submit">Sign out</button></form>
{% else %}
Expand All @@ -55,6 +55,23 @@
<a class="primary-btn compact" href="{{ url_for('staff_invite', token=staff_invite.token) }}">See the invitation</a>
</div>
{% endif %}
{% for check in handover_checks %}
<div class="notice">
<p>The security desk logged your <strong>{{ check.title }}</strong> as received from you. Was that you?</p>
<form class="actions" method="post" action="{{ url_for('answer_handover_check', item_id=check.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path if request.method == 'GET' else url_for('index') }}">
<button class="secondary-btn compact" type="submit" name="answer" value="no">That wasn't me</button>
<button class="primary-btn compact" type="submit" name="answer" value="yes">Yes, that was me</button>
</form>
</div>
{% endfor %}
{% if desk_queue and request.endpoint != "desk" %}
<div class="notice warn">
<p><strong>{{ desk_queue }}</strong> item{{ "s" if desk_queue != 1 }} handed in by finders {{ "are" if desk_queue != 1 else "is" }} waiting for the desk to confirm.</p>
<a class="primary-btn compact" href="{{ url_for('desk') }}">Open the desk</a>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="footer"><span>ClassFind · BIT campus lost and found</span><span>Find it. Report it. Return it.</span></footer>
Expand Down
41 changes: 41 additions & 0 deletions templates/desk.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ <h1>Every found item, accounted for.</h1>
</section>

<section class="claim-overview desk-overview">
<div class="claim-overview-card"><span>To confirm</span><strong>{{ handed|length }}</strong><small>Finder says it's handed in</small></div>
<div class="claim-overview-card"><span>Awaiting drop-off</span><strong>{{ awaiting|length }}</strong><small>Reported, not yet handed in</small></div>
<div class="claim-overview-card"><span>Held at desk</span><strong>{{ held|length }}</strong><small>In storage now</small></div>
<div class="claim-overview-card"><span>Claims to review</span><strong>{{ pending|length }}</strong><small>Check the proof</small></div>
Expand All @@ -32,6 +33,46 @@ <h2>Enter the owner's collection code</h2>
</form>
</section>

{% if handed %}
<section class="claim-section">
<div class="results-header"><div><p class="section-kicker">HANDED IN, PLEASE CONFIRM</p><h2>{{ handed|length }} to check</h2></div><span class="queue-note">The finder says these are at the desk</span></div>
<div class="claim-list">
{% for item in handed %}
<article class="claim-card claim-pending">
<div class="claim-main">
<span class="item-category">{{ item.category }}</span>
<h3><a href="{{ url_for('item_detail', item_id=item.id) }}">{{ item.title }}</a></h3>
<p class="claim-by">Handed in by {{ item.reporter_name }} · ⌖ {{ item.location }}</p>
<small>Reported {{ item.created_at.strftime("%d %b %Y, %I:%M %p") }}</small>
</div>
<div class="claim-actions">
<form method="post" action="{{ url_for('desk_receive', item_id=item.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="primary-btn compact" type="submit">Confirm received</button></form>
<form method="post" action="{{ url_for('desk_not_received', item_id=item.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="danger-btn compact-btn" type="submit">Not received</button></form>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}

{% if disputed %}
<section class="claim-section">
<div class="notice warn"><p><strong>{{ disputed|length }}</strong> 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.</p></div>
<div class="claim-list">
{% for item in disputed %}
<article class="claim-card">
<div class="claim-main">
<h3><a href="{{ url_for('item_detail', item_id=item.id) }}">{{ item.title }}</a></h3>
<p class="claim-by">Reported by {{ item.reporter_name }} · {{ item.custody_label }}</p>
</div>
<div class="claim-actions"><a class="card-link" href="{{ url_for('item_detail', item_id=item.id) }}">Custody log →</a></div>
</article>
{% endfor %}
</div>
</section>
{% endif %}


<section class="claim-section">
<div class="results-header"><div><p class="section-kicker">CLAIMS TO REVIEW</p><h2>{{ pending|length }} waiting</h2></div><span class="queue-note">Approve only when the proof matches the item</span></div>
{% if pending %}
Expand Down
9 changes: 9 additions & 0 deletions templates/item.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ <h1>{{ item.title }}</h1>
<div><span>Contact</span><strong><a href="{{ url_for('login', next=request.path) }}">Sign in to see contact</a></strong></div>
{% endif %}
</div>
{% if can_manage and item.status == "Found" and item.custody == "awaiting" and item.owner_id == current_user.id %}
<form class="notice" method="post" action="{{ url_for('mark_handed_in', item_id=item.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<p>Have you handed this in at the security desk in the Main Block?</p>
<button class="primary-btn compact" type="submit">I've handed it in</button>
</form>
{% elif can_manage and item.custody == "handed" %}
<div class="notice"><p>You marked this as handed in. The desk will confirm once they've checked.</p></div>
{% endif %}
{% if can_manage %}
<div class="detail-actions">
<a class="secondary-btn" href="{{ url_for('edit_item', item_id=item.id) }}">Edit report</a>
Expand Down
2 changes: 1 addition & 1 deletion templates/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
{% if items %}
<section class="item-grid">{% for item in items %}
<article class="item-card">
<div class="item-visual">{% if item.image_src %}<img src="{{ item.image_src }}" alt="{{ item.title }}">{% else %}<span>{{ item.category[:1].upper() }}</span>{% endif %}<span class="status-pill {{ item.status_class }}">{{ item.status }}</span></div>
<div class="item-visual">{% if item.image_src %}<img src="{{ item.image_src }}" alt="{{ item.title }}">{% else %}<span>{{ item.category[:1].upper() }}</span>{% endif %}<span class="status-pill {{ item.status_class }}">{{ item.status }}</span>{% if item.custody in ("awaiting", "handed") %}<span class="desk-chip">{{ "Waiting for the desk" if item.custody == "handed" else "Hand it in" }}</span>{% endif %}</div>
<div class="item-body"><p class="item-category">{{ item.category }}</p><h3>{{ item.title }}</h3><p class="item-description">{{ item.description }}</p><div class="item-meta"><span>⌖ {{ item.location }}</span><span>{{ item.created_at.strftime("%d %b %Y") }}</span></div><div class="card-actions"><a class="card-link" href="{{ url_for('item_detail', item_id=item.id) }}">View →</a><a class="card-link" href="{{ url_for('edit_item', item_id=item.id) }}">Edit →</a></div></div>
</article>
{% endfor %}</section>
Expand Down
Loading
Loading