P2: nearby units CRUD, unit meeting times, site settings - the first admin writes
Nearby rows bump verified_at on every save and deactivate rather than delete. Unit edits are meeting fields only, gated by unit:write_own scoped to the unit in the URL. Settings are a typed key registry falling back to code defaults; find-a-unit now reads its source name and URL from it. link_url and contact reject attribute-breakout characters and the nearby renderer escapes quotes. Covered by tests/smoke_admin.py, 53 checks in-process.
This commit is contained in:
+117
-3
@@ -51,17 +51,23 @@ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _auth(request, token, capability):
|
||||
def _auth(request, token, capability, unit_id=None):
|
||||
"""Authorise, and return a label naming who acted, for created_by.
|
||||
|
||||
Order matters: session first, so a normal signed-in leader never depends on
|
||||
the shared token, and the token stays a fallback rather than the everyday
|
||||
path.
|
||||
|
||||
unit_id scopes a membership capability to one unit: a pack leader holds
|
||||
unit:write_own, but only within the pack. Global roles pass a scoped check
|
||||
for every unit, and the break-glass token reaches everything - it exists
|
||||
for when identity is broken, so it cannot depend on identity's scoping.
|
||||
"""
|
||||
person = auth.current_person(request)
|
||||
if person:
|
||||
if not identity.can(person, capability):
|
||||
raise HTTPException(403, "your account does not have %s" % capability)
|
||||
if not identity.can(person, capability, unit_id):
|
||||
raise HTTPException(403, "your account does not have %s" % capability
|
||||
+ (" for this unit" if unit_id else ""))
|
||||
return person["email"]
|
||||
if not ADMIN_TOKEN:
|
||||
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
|
||||
@@ -179,3 +185,111 @@ def revoke_announcement(request: Request, announcement_id: str, x_admin_token: s
|
||||
if not store.revoke_announcement(announcement_id):
|
||||
raise HTTPException(409, "already revoked")
|
||||
return store.get_announcement(announcement_id)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Nearby units - the /find-a-unit directory, and the first writable directory
|
||||
# data. The verified_at and deactivate-not-delete rules live in store.py so
|
||||
# any future client inherits them.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@router.get("/nearby")
|
||||
def list_nearby(request: Request, include_inactive: bool = False,
|
||||
x_admin_token: str = Header(None)):
|
||||
"""The editing view, so deactivated rows are reachable. nearby:write
|
||||
rather than a read capability: the public page IS the read surface, and
|
||||
this list exists only to be edited."""
|
||||
_auth(request, x_admin_token, "nearby:write")
|
||||
return {"nearby_units": store.list_nearby(include_inactive=include_inactive)}
|
||||
|
||||
|
||||
@router.post("/nearby", status_code=201)
|
||||
def create_nearby(request: Request, payload: dict = Body(...),
|
||||
x_admin_token: str = Header(None)):
|
||||
_auth(request, x_admin_token, "nearby:write")
|
||||
try:
|
||||
return store.create_nearby(payload)
|
||||
except store.NearbyRejected as e:
|
||||
raise _reject(e)
|
||||
|
||||
|
||||
@router.patch("/nearby/{nearby_id}")
|
||||
def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
|
||||
x_admin_token: str = Header(None)):
|
||||
"""Partial update. Saving bumps verified_at to today unless the payload
|
||||
carries an explicit date - see store.py for why saving is verifying."""
|
||||
_auth(request, x_admin_token, "nearby:write")
|
||||
try:
|
||||
rec = store.update_nearby(nearby_id, payload)
|
||||
except store.NearbyRejected as e:
|
||||
raise _reject(e)
|
||||
if not rec:
|
||||
raise HTTPException(404, "no such nearby unit")
|
||||
return rec
|
||||
|
||||
|
||||
@router.delete("/nearby/{nearby_id}")
|
||||
def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Header(None)):
|
||||
"""Take a unit off the page. Sets active=0; never deletes the row."""
|
||||
_auth(request, x_admin_token, "nearby:write")
|
||||
if not store.get_nearby(nearby_id):
|
||||
raise HTTPException(404, "no such nearby unit")
|
||||
if not store.deactivate_nearby(nearby_id):
|
||||
raise HTTPException(409, "already inactive")
|
||||
return store.get_nearby(nearby_id)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Our own units - meeting time and place only. unit:write_own is checked
|
||||
# against the unit in the URL, so a pack leader edits the pack and not the
|
||||
# troop; admins hold it globally and reach both.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@router.get("/units")
|
||||
def list_units(request: Request, x_admin_token: str = Header(None)):
|
||||
"""The units the caller may edit, which is what the screen this feeds
|
||||
shows. A pack leader gets the pack; a global role or the break-glass
|
||||
token gets everything. The answer IS the scope - no second filter for
|
||||
the console to get wrong."""
|
||||
_auth(request, x_admin_token, "unit:write_own")
|
||||
units = identity.list_units(include_inactive=True)
|
||||
person = auth.current_person(request)
|
||||
if person:
|
||||
units = [u for u in units if identity.can(person, "unit:write_own", u["id"])]
|
||||
return {"units": units}
|
||||
|
||||
|
||||
@router.patch("/units/{slug_or_id}")
|
||||
def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
|
||||
x_admin_token: str = Header(None)):
|
||||
unit = identity.get_unit(slug_or_id)
|
||||
# Scope to the unit when it exists; an unknown slug still goes through
|
||||
# _auth first, so probing paths answers 401/403 before it answers 404.
|
||||
_auth(request, x_admin_token, "unit:write_own",
|
||||
unit_id=unit["id"] if unit else None)
|
||||
try:
|
||||
return identity.update_unit_meets(slug_or_id, payload)
|
||||
except identity.IdentityError as e:
|
||||
raise HTTPException(e.status, e.detail)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Site settings - the typed key registry lives in identity.SETTINGS_KEYS;
|
||||
# unknown keys are rejected there, not silently stored.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@router.get("/settings")
|
||||
def list_settings(request: Request, x_admin_token: str = Header(None)):
|
||||
_auth(request, x_admin_token, "settings:write")
|
||||
return {"settings": identity.all_settings()}
|
||||
|
||||
|
||||
@router.put("/settings/{key}")
|
||||
def put_setting(request: Request, key: str, payload: dict = Body(...),
|
||||
x_admin_token: str = Header(None)):
|
||||
"""Set one value, or clear it back to the code default with value: null."""
|
||||
actor = _auth(request, x_admin_token, "settings:write")
|
||||
try:
|
||||
return identity.set_setting(key, payload.get("value"), actor=actor)
|
||||
except identity.IdentityError as e:
|
||||
raise HTTPException(e.status, e.detail)
|
||||
|
||||
+148
@@ -333,6 +333,56 @@ def get_unit(slug_or_id):
|
||||
con.close()
|
||||
|
||||
|
||||
# The only unit fields the admin API lets a leader change. Renaming a unit or
|
||||
# changing its type is a rechartering event, not a Tuesday edit, and stays a
|
||||
# code change.
|
||||
UNIT_MEETS_FIELDS = ("meets_weekday", "meets_time", "meets_at")
|
||||
|
||||
|
||||
def update_unit_meets(slug_or_id, fields):
|
||||
"""Change when and where a unit meets.
|
||||
|
||||
meets_weekday is ISO (Monday=1 .. Sunday=7) and meets_time is 24h "HH:MM",
|
||||
matching how the seed rows store them; the site composes the display
|
||||
sentence, so a display string is never accepted here. Any field may be set
|
||||
to null - a unit between meeting places is a real state.
|
||||
"""
|
||||
unit = get_unit(slug_or_id)
|
||||
if not unit:
|
||||
raise IdentityError(404, "no such unit")
|
||||
if not fields:
|
||||
raise IdentityError(422, "nothing to update")
|
||||
unknown = sorted(set(fields) - set(UNIT_MEETS_FIELDS))
|
||||
if unknown:
|
||||
raise IdentityError(422, "only meeting fields are editable here: %s"
|
||||
% (", ".join(UNIT_MEETS_FIELDS)))
|
||||
|
||||
out = dict(fields)
|
||||
if "meets_weekday" in out and out["meets_weekday"] is not None:
|
||||
v = out["meets_weekday"]
|
||||
if isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 7:
|
||||
raise IdentityError(422, "meets_weekday is ISO: 1 (Monday) to 7 (Sunday), or null")
|
||||
if "meets_time" in out and out["meets_time"] is not None:
|
||||
try:
|
||||
datetime.datetime.strptime(str(out["meets_time"]), "%H:%M")
|
||||
except ValueError:
|
||||
raise IdentityError(422, 'meets_time must be 24h "HH:MM", or null')
|
||||
out["meets_time"] = str(out["meets_time"])
|
||||
if "meets_at" in out and out["meets_at"] is not None:
|
||||
out["meets_at"] = str(out["meets_at"]).strip() or None
|
||||
|
||||
out["updated_at"] = _now()
|
||||
con = connect()
|
||||
try:
|
||||
con.execute("UPDATE units SET %s WHERE id=?"
|
||||
% ", ".join("%s=?" % k for k in out),
|
||||
list(out.values()) + [unit["id"]])
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return get_unit(unit["id"])
|
||||
|
||||
|
||||
def _person_row(con, r):
|
||||
if not r:
|
||||
return None
|
||||
@@ -710,3 +760,101 @@ def bootstrap():
|
||||
except Exception as e:
|
||||
print("identity: bootstrap failed: %s" % e, flush=True)
|
||||
return None, False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site settings
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Typed key-value pairs for the handful of site-wide values that change more
|
||||
# often than the code does. SETTINGS_KEYS is the whole contract: a key not in
|
||||
# it cannot be written, and a key absent from the table - or holding a value
|
||||
# its checker no longer accepts - falls back to the code default. A mangled
|
||||
# row can make the site stale, never make it crash.
|
||||
#
|
||||
# Setting a value to null clears the row, which IS the fallback: there is no
|
||||
# stored-but-empty state to reason about.
|
||||
|
||||
def _setting_text(v):
|
||||
v = str(v).strip()
|
||||
if not v:
|
||||
raise IdentityError(422, "value must not be blank; send null to clear to the default")
|
||||
return v
|
||||
|
||||
|
||||
def _setting_https_url(v):
|
||||
v = _setting_text(v)
|
||||
if not v.startswith("https://") or any(c in v for c in " \"'<>"):
|
||||
raise IdentityError(422, "value must be a plain https:// URL")
|
||||
return v
|
||||
|
||||
|
||||
# key -> (code default, checker)
|
||||
SETTINGS_KEYS = {
|
||||
"nearby_source_name": ("Continental District unit list", _setting_text),
|
||||
"nearby_source_url": ("https://tinyurl.com/ContinentalScouts", _setting_https_url),
|
||||
}
|
||||
|
||||
|
||||
def get_setting(key):
|
||||
"""The effective value: the stored one if present and still valid, else
|
||||
the code default. Unknown keys are a programming error and raise."""
|
||||
default, check = SETTINGS_KEYS[key]
|
||||
con = connect()
|
||||
try:
|
||||
r = con.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
if not r:
|
||||
return default
|
||||
try:
|
||||
return check(r["value"])
|
||||
except IdentityError:
|
||||
return default
|
||||
|
||||
|
||||
def all_settings():
|
||||
"""Every known key with its effective value, for the settings screen.
|
||||
Rows for keys the registry no longer knows are not shown - they are dead
|
||||
weight, not settings."""
|
||||
con = connect()
|
||||
try:
|
||||
stored = {r["key"]: dict(r) for r in con.execute("SELECT * FROM settings")}
|
||||
finally:
|
||||
con.close()
|
||||
out = []
|
||||
for key, (default, _check) in SETTINGS_KEYS.items():
|
||||
row = stored.get(key)
|
||||
out.append({
|
||||
"key": key,
|
||||
"value": get_setting(key),
|
||||
"default": default,
|
||||
"is_set": row is not None,
|
||||
"updated_at": row["updated_at"] if row else None,
|
||||
"updated_by": row["updated_by"] if row else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def set_setting(key, value, actor=None):
|
||||
"""Write one setting, or clear it back to the default with value=null."""
|
||||
if key not in SETTINGS_KEYS:
|
||||
raise IdentityError(422, "unknown setting %r. Known keys: %s"
|
||||
% (key, ", ".join(sorted(SETTINGS_KEYS))))
|
||||
default, check = SETTINGS_KEYS[key]
|
||||
con = connect()
|
||||
try:
|
||||
if value is None:
|
||||
con.execute("DELETE FROM settings WHERE key=?", (key,))
|
||||
else:
|
||||
value = check(value)
|
||||
con.execute(
|
||||
"INSERT INTO settings (key, value, updated_at, updated_by)"
|
||||
" VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET"
|
||||
" value=excluded.value, updated_at=excluded.updated_at,"
|
||||
" updated_by=excluded.updated_by",
|
||||
(key, value, _now(), actor))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return [s for s in all_settings() if s["key"] == key][0]
|
||||
|
||||
+10
-9
@@ -17,6 +17,7 @@ and from this page. The rest of the site is our pitch; this one is a door out
|
||||
for a family we are not the right fit for.
|
||||
"""
|
||||
|
||||
import identity
|
||||
import store
|
||||
|
||||
# unit_type -> the badge a parent actually recognises.
|
||||
@@ -39,12 +40,10 @@ SERVES_LABEL = {
|
||||
|
||||
BEASCOUT = "https://beascout.scouting.org"
|
||||
|
||||
# The district's own short link to its unit list. This is the page the district
|
||||
# hands out, so it is the right thing to credit and the right thing to link.
|
||||
# One URL for the whole page rather than a per-row column: when the admin panel
|
||||
# exists this belongs in a settings row, not in seventeen copies.
|
||||
SOURCE_NAME = "Continental District unit list"
|
||||
SOURCE_URL = "https://tinyurl.com/ContinentalScouts"
|
||||
# The district's short link to its unit list is one URL for the whole page
|
||||
# rather than a per-row column, and it now lives in settings (defaults and
|
||||
# validation in identity.SETTINGS_KEYS) so the admin panel can change it when
|
||||
# the district moves the document.
|
||||
|
||||
MONTHS = ["January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December"]
|
||||
@@ -103,8 +102,10 @@ def checked_on():
|
||||
|
||||
|
||||
def _esc(s):
|
||||
"""Quotes included: several of these values are interpolated into
|
||||
href="..." attributes, where a bare quote is a breakout."""
|
||||
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
if s else "")
|
||||
.replace('"', """) if s else "")
|
||||
|
||||
|
||||
def unit_name(u):
|
||||
@@ -176,8 +177,8 @@ def body_html(hero_html):
|
||||
'has every unit in the area.</div>')
|
||||
|
||||
checked = checked_on()
|
||||
src = (f'<a class="nu-src" href="{SOURCE_URL}" target="_blank" '
|
||||
f'rel="noopener">{SOURCE_NAME}</a>')
|
||||
src = (f'<a class="nu-src" href="{_esc(identity.get_setting("nearby_source_url"))}" target="_blank" '
|
||||
f'rel="noopener">{_esc(identity.get_setting("nearby_source_name"))}</a>')
|
||||
stamp = (f'Pulled from the {src} on {pretty_date(checked)}.' if checked
|
||||
else f'Pulled from the {src}.')
|
||||
|
||||
|
||||
+171
-2
@@ -46,6 +46,14 @@ ANNOUNCEMENT_MAX_CHARS = 200
|
||||
ANNOUNCEMENT_MAX_LIVE = 3
|
||||
ANNOUNCEMENT_LEVELS = ("info", "urgent")
|
||||
|
||||
# nearby_units guardrails. pack/troop/ship/club get a recognisable badge on
|
||||
# /find-a-unit; crew and post are real unit types the district may list and
|
||||
# render with the generic card. Anything else is a typo, and a typo'd type
|
||||
# would silently render a unit as generic "Scouting" rather than fail, so it
|
||||
# is rejected at write time instead.
|
||||
NEARBY_UNIT_TYPES = ("pack", "troop", "crew", "ship", "post", "club")
|
||||
NEARBY_SERVES = ("family", "boys", "girls", "coed")
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
@@ -403,9 +411,9 @@ def _backfill(con, path):
|
||||
# record of what the site said, and when, survives.
|
||||
|
||||
|
||||
class AnnouncementRejected(Exception):
|
||||
class Rejected(Exception):
|
||||
"""Raised when a write breaks a guardrail. Carries the HTTP status the
|
||||
admin API should return, so the caps live here rather than in the route."""
|
||||
admin API should return, so the rules live here rather than in the route."""
|
||||
|
||||
def __init__(self, status, detail, extra=None):
|
||||
super().__init__(detail)
|
||||
@@ -414,6 +422,10 @@ class AnnouncementRejected(Exception):
|
||||
self.extra = extra or {}
|
||||
|
||||
|
||||
class AnnouncementRejected(Rejected):
|
||||
pass
|
||||
|
||||
|
||||
def _live_at(con, when):
|
||||
return con.execute(
|
||||
"SELECT * FROM announcements"
|
||||
@@ -539,3 +551,160 @@ def revoke_announcement(aid):
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Nearby units - writes behind the /find-a-unit courtesy directory.
|
||||
#
|
||||
# This is somebody else's data, hand-copied from a district document, and two
|
||||
# rules follow from that.
|
||||
#
|
||||
# verified_at is bumped to today on every row write unless the caller passes
|
||||
# one explicitly. Saving a row IS the claim that a person just checked it
|
||||
# against the district list - verified_at is the date the honesty line on the
|
||||
# public page shows a family, not bookkeeping.
|
||||
#
|
||||
# Rows are deactivated, never deleted. A unit that folds or moves keeps its
|
||||
# row with active=0, so "why did that pack disappear from the page" stays
|
||||
# answerable. Reactivation is an update setting active back to 1.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NearbyRejected(Rejected):
|
||||
pass
|
||||
|
||||
|
||||
# Everything a caller may set. id and updated_at are the store's own.
|
||||
NEARBY_FIELDS = ("unit_type", "unit_number", "serves", "chartered_org", "street",
|
||||
"town", "area", "meets", "notes", "link_url", "contact",
|
||||
"sort_order", "active", "source", "verified_at")
|
||||
|
||||
|
||||
def _clean_nearby(fields, creating):
|
||||
"""Validate and normalise a payload. Unknown keys are rejected rather than
|
||||
dropped - a silently ignored typo ("unit_typo": "pack") would read as a
|
||||
successful save that changed nothing."""
|
||||
unknown = sorted(set(fields) - set(NEARBY_FIELDS))
|
||||
if unknown:
|
||||
raise NearbyRejected(422, "unknown fields: %s. Editable fields are %s"
|
||||
% (", ".join(unknown), ", ".join(NEARBY_FIELDS)))
|
||||
|
||||
out = {}
|
||||
for k, v in fields.items():
|
||||
if isinstance(v, str):
|
||||
v = v.strip() or None
|
||||
out[k] = v
|
||||
|
||||
if creating or "unit_type" in out:
|
||||
if out.get("unit_type") not in NEARBY_UNIT_TYPES:
|
||||
raise NearbyRejected(422, "unit_type must be one of %s" % (NEARBY_UNIT_TYPES,))
|
||||
if creating or "unit_number" in out:
|
||||
if not out.get("unit_number"):
|
||||
raise NearbyRejected(422, "unit_number is required")
|
||||
out["unit_number"] = str(out["unit_number"])
|
||||
if out.get("serves") is not None and out["serves"] not in NEARBY_SERVES:
|
||||
raise NearbyRejected(422, "serves must be one of %s, or null" % (NEARBY_SERVES,))
|
||||
# Both of these land inside href="..." attributes on the public page, so a
|
||||
# quote or bracket is an attribute breakout, not a formatting nit. Same
|
||||
# character set identity._setting_https_url blocks, for the same reason.
|
||||
if out.get("link_url") is not None:
|
||||
v = str(out["link_url"])
|
||||
if not v.startswith("https://") or any(c in v for c in " \"'<>"):
|
||||
raise NearbyRejected(422, "link_url must be a plain https:// URL")
|
||||
if out.get("contact") is not None:
|
||||
v = str(out["contact"])
|
||||
if "@" not in v or any(c in v for c in " \"'<>"):
|
||||
raise NearbyRejected(422, "contact is rendered as a mailto: link and must be an email address")
|
||||
if "sort_order" in out and out["sort_order"] is not None:
|
||||
try:
|
||||
out["sort_order"] = int(out["sort_order"])
|
||||
except (TypeError, ValueError):
|
||||
raise NearbyRejected(422, "sort_order must be an integer")
|
||||
if "active" in out:
|
||||
if out["active"] not in (0, 1, True, False):
|
||||
raise NearbyRejected(422, "active must be 0 or 1")
|
||||
out["active"] = int(out["active"])
|
||||
if "verified_at" in out and out["verified_at"] is not None:
|
||||
try:
|
||||
datetime.date.fromisoformat(str(out["verified_at"]))
|
||||
except ValueError:
|
||||
raise NearbyRejected(422, "verified_at must be a plain YYYY-MM-DD date")
|
||||
out["verified_at"] = str(out["verified_at"])
|
||||
return out
|
||||
|
||||
|
||||
def _today():
|
||||
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
def list_nearby(include_inactive=False):
|
||||
con = connect()
|
||||
try:
|
||||
sql = "SELECT * FROM nearby_units"
|
||||
if not include_inactive:
|
||||
sql += " WHERE active = 1"
|
||||
sql += " ORDER BY sort_order, unit_number"
|
||||
return [dict(r) for r in con.execute(sql).fetchall()]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def get_nearby(nid):
|
||||
con = connect()
|
||||
try:
|
||||
row = con.execute("SELECT * FROM nearby_units WHERE id=?", (nid,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def create_nearby(fields):
|
||||
out = _clean_nearby(fields or {}, creating=True)
|
||||
if not out.get("verified_at"):
|
||||
out["verified_at"] = _today()
|
||||
out.setdefault("active", 1)
|
||||
out.setdefault("sort_order", 100)
|
||||
nid = str(uuid.uuid4())
|
||||
cols = ["id"] + list(out) + ["updated_at"]
|
||||
vals = [nid] + [out[k] for k in out] + [_now()]
|
||||
con = connect()
|
||||
try:
|
||||
con.execute("INSERT INTO nearby_units (%s) VALUES (%s)"
|
||||
% (", ".join(cols), ", ".join("?" * len(cols))), vals)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return get_nearby(nid)
|
||||
|
||||
|
||||
def update_nearby(nid, fields):
|
||||
if not fields:
|
||||
raise NearbyRejected(422, "nothing to update")
|
||||
out = _clean_nearby(fields, creating=False)
|
||||
if not out.get("verified_at"):
|
||||
out["verified_at"] = _today()
|
||||
out["updated_at"] = _now()
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.execute("UPDATE nearby_units SET %s WHERE id=?"
|
||||
% ", ".join("%s=?" % k for k in out),
|
||||
list(out.values()) + [nid])
|
||||
con.commit()
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
finally:
|
||||
con.close()
|
||||
return get_nearby(nid)
|
||||
|
||||
|
||||
def deactivate_nearby(nid):
|
||||
"""Take a unit off the page. Sets active=0; the row and its history stay."""
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.execute(
|
||||
"UPDATE nearby_units SET active=0, updated_at=? WHERE id=? AND active=1",
|
||||
(_now(), nid))
|
||||
con.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
Reference in New Issue
Block a user