From 41ff0f0ca426d329e3585367d56a2978bcfa6a02 Mon Sep 17 00:00:00 2001 From: Aron Granberg Date: Tue, 17 Jun 2025 16:09:42 +0200 Subject: [PATCH 1/6] Groups now contain a list of doors they give access to, and this is synced automatically to accessy. --- admin/dist/css/default.css | 6 + admin/src/Membership/GroupBox.jsx | 6 + admin/src/Membership/GroupBoxDoorAccess.tsx | 192 +++++++++++++++ .../src/Membership/MembershipPeriodsInput.js | 1 - admin/src/Membership/Routes.jsx | 2 + admin/src/Models/Collection.ts | 22 +- admin/src/Models/GroupDoorAccess.ts | 18 ++ api/src/membership/models.py | 16 ++ api/src/membership/views.py | 24 +- .../0033_group_accessy_permissions.sql | 11 + api/src/multiaccessy/accessy.py | 172 +++++++++++++- api/src/multiaccessy/sync.py | 221 ++++++++++++++---- api/src/multiaccessy/views.py | 45 +++- api/src/service/internal_service.py | 6 +- api/src/systest/api/accessy_sync_test.py | 53 +++-- 15 files changed, 714 insertions(+), 81 deletions(-) create mode 100644 admin/src/Membership/GroupBoxDoorAccess.tsx create mode 100644 admin/src/Models/GroupDoorAccess.ts create mode 100644 api/src/migrations/0033_group_accessy_permissions.sql diff --git a/admin/dist/css/default.css b/admin/dist/css/default.css index d21bc073f..b7bf8ee74 100644 --- a/admin/dist/css/default.css +++ b/admin/dist/css/default.css @@ -384,3 +384,9 @@ button > svg { .removebutton { color: #a00000; } + +.accessy-asset-img-thumbnail { + width: 70px; + height: 70px; + object-fit: cover; +} diff --git a/admin/src/Membership/GroupBox.jsx b/admin/src/Membership/GroupBox.jsx index 530e60a4a..164b9f9ec 100644 --- a/admin/src/Membership/GroupBox.jsx +++ b/admin/src/Membership/GroupBox.jsx @@ -40,6 +40,12 @@ function GroupBox(props) { > Behörigheter + + Dörråtkomst + {props.children} diff --git a/admin/src/Membership/GroupBoxDoorAccess.tsx b/admin/src/Membership/GroupBoxDoorAccess.tsx new file mode 100644 index 000000000..dd56d34a2 --- /dev/null +++ b/admin/src/Membership/GroupBoxDoorAccess.tsx @@ -0,0 +1,192 @@ +import GroupDoorAccess from "Models/GroupDoorAccess"; +import React, { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import Select from "react-select"; +import CollectionTable from "../Components/CollectionTable"; +import Icon from "../Components/icons"; +import Collection from "../Models/Collection"; +import { get, post } from "../gateway"; + +interface AccessyAsset { + id: string; + createdAt: string; + updatedAt: string; + name: string; + description: string; + address: object; + images: { + id: string; + imageId: string; + thumbnailId: string; + size: number; + width: number; + height: number; + }[]; + roomType: string; + category: string; + status: string; +} + +interface AccessyAssetPublication { + id: string; +} + +interface AccessyAssetWithPublication { + asset: AccessyAsset; + publication: AccessyAssetPublication; +} + +const GroupBoxDoorAccess = () => { + const { group_id } = useParams<{ group_id: string }>(); + const group_id_num = Number(group_id); + const [selectedOption, setSelectedOption] = + useState(null); + const [options, setOptions] = useState([]); + const [items, setItems] = useState([]); + const filteredOptions = useMemo(() => { + return options.filter((option) => { + return !items.some( + (item) => + item.accessy_asset_publication_guid === + option.publication.id, + ); + }); + }, [options, items]); + + const collection = useMemo( + () => + new Collection({ + type: GroupDoorAccess, + url: `/membership/group/${group_id}/door_access_permissions`, + idListName: "door_access_permissions", + pageSize: 0, + }), + [group_id], + ); + + useEffect(() => { + get({ url: "/accessy/assets" }).then(({ data }) => { + setOptions(data); + }); + }, []); + + useEffect(() => { + // Purely for the rendering side-effect + const unsubscribe = collection.subscribe(({ items: newItems }) => { + setItems(newItems); + }); + + return () => { + unsubscribe(); + }; + }, [collection]); + + const selectOption = async (item: AccessyAssetWithPublication | null) => { + setSelectedOption(item); + + if (item !== null) { + let access = new GroupDoorAccess({ + group_id: group_id_num, + accessy_asset_publication_guid: item.publication.id, + }); + await access.save(); + // await collection.add(access); + setSelectedOption(null); + await collection.fetch(); + await post({ + url: `/accessy/sync`, + expectedDataStatus: "ok", + }); + } + }; + + const columns = [{ title: "Tillgångar" }]; + + const Row = ({ item }: { item: GroupDoorAccess }) => { + const asset = options.find( + (o) => o.publication.id === item.accessy_asset_publication_guid, + ); + return ( + + + {asset?.asset.images.map((img) => ( + + ))} + + + {asset !== undefined ? asset.asset.name : "Unknown Asset"} + + + { + await item.del(); + await collection.fetch(); + await post({ + url: `/accessy/sync`, + expectedDataStatus: "ok", + }); + }} + className="removebutton" + > + + + + + ); + }; + + return ( +
+
+ +
+ + name="asset" + className="uk-width-1-1" + tabIndex={1} + options={filteredOptions} + value={selectedOption} + getOptionValue={(p) => p.publication.id} + getOptionLabel={(p) => p.asset.name} + formatOptionLabel={(p) => ( +
+ {p.asset.images.length > 0 ? ( + + ) : null} + + {p.asset.name} + +
+ )} + onChange={(p) => selectOption(p)} + isDisabled={!filteredOptions.length} + /> +
+
+
+ +
+
+ ); +}; + +export default GroupBoxDoorAccess; diff --git a/admin/src/Membership/MembershipPeriodsInput.js b/admin/src/Membership/MembershipPeriodsInput.js index 109d1a4f9..ca308d6e2 100644 --- a/admin/src/Membership/MembershipPeriodsInput.js +++ b/admin/src/Membership/MembershipPeriodsInput.js @@ -82,7 +82,6 @@ function MembershipPeriodsInput({ spans, member_id }) { post({ url: `/webshop/member/${member_id}/ship_labaccess_orders`, headers: { "Content-Type": "application/json" }, - payload: {}, expectedDataStatus: "ok", }); }); diff --git a/admin/src/Membership/Routes.jsx b/admin/src/Membership/Routes.jsx index b7b30b37c..14785cc49 100644 --- a/admin/src/Membership/Routes.jsx +++ b/admin/src/Membership/Routes.jsx @@ -3,6 +3,7 @@ import { Route, Switch } from "react-router-dom"; import { defaultSubpageRoute } from "../Components/Routes"; import GroupAdd from "./GroupAdd"; import GroupBox from "./GroupBox"; +import GroupBoxDoorAccess from "./GroupBoxDoorAccess"; import GroupBoxEditInfo from "./GroupBoxEditInfo"; import GroupBoxMembers from "./GroupBoxMembers"; import GroupBoxPermissions from "./GroupBoxPermissions"; @@ -36,6 +37,7 @@ const Group = ({ match: { path } }) => ( path={`${path}/permissions`} component={GroupBoxPermissions} /> + ); diff --git a/admin/src/Models/Collection.ts b/admin/src/Models/Collection.ts index a322dabce..66c5a7b32 100644 --- a/admin/src/Models/Collection.ts +++ b/admin/src/Models/Collection.ts @@ -10,7 +10,7 @@ import { get, post } from "../gateway"; // idListName: used for add and remove if collection supports it by pushing id list to to /remove or /add, // this could be simpler if server handled removes in a better way export default class Collection { - type: { new (data?: T | null): T; model: { root: string } }; + type: { new (data?: T | null): T; model: { root?: string } }; pageSize: number; url: string; idListName: string | null; @@ -47,7 +47,7 @@ export default class Collection { filter_out_value = null, includeDeleted = false, }: { - type: { new (data?: T | null): T; model: { root: string } }; + type: { new (data?: T | null): T; model: { root?: string } }; pageSize?: number; expand?: string | null; sort?: { key?: string; order?: string }; @@ -61,7 +61,12 @@ export default class Collection { }) { this.type = type; this.pageSize = pageSize; - this.url = url || type.model.root; + this.url = url || type.model.root || ""; + if (this.url == "") { + throw new Error( + `Collection for ${this.type.constructor.name} does not have a valid URL.`, + ); + } this.idListName = idListName; this.items = null; @@ -84,7 +89,7 @@ export default class Collection { // Subscribe to data from server {items, page}, returns function for unsubscribing. subscribe( callback: (data: { - items: any[]; + items: T[]; page: { index: number; count: number }; }) => void, ) { @@ -124,12 +129,17 @@ export default class Collection { } // Remove an item from this collection. - remove(item: { id: number }) { + remove(item: { id: number | null }) { if (!this.idListName) { throw new Error( `Container for ${this.type.constructor.name} does not support remove.`, ); } + if (item.id === null) { + throw new Error( + `Cannot remove item with null ID from ${this.type.constructor.name} collection.`, + ); + } return post({ url: this.url + "/remove", @@ -139,7 +149,7 @@ export default class Collection { } // Add an item to this collection. - add(item: { id: number }) { + add(item: { id: number | null }) { if (!this.idListName) { throw new Error( `Container for ${this.type.constructor.name} does not support add.`, diff --git a/admin/src/Models/GroupDoorAccess.ts b/admin/src/Models/GroupDoorAccess.ts new file mode 100644 index 000000000..02d9c4d5c --- /dev/null +++ b/admin/src/Models/GroupDoorAccess.ts @@ -0,0 +1,18 @@ +import Base from "./Base"; + +export default class GroupDoorAccess extends Base { + group_id!: number; + accessy_asset_publication_guid!: string; + created_at!: Date | null; + updated_at!: Date | null; + deleted_at!: Date | null; + + static model = { + root: "/membership/group_door_access_permissions", + id: "id", + attributes: { + accessy_asset_publication_guid: null, + deleted_at: null, + }, + }; +} diff --git a/api/src/membership/models.py b/api/src/membership/models.py index 028804626..40690e601 100644 --- a/api/src/membership/models.py +++ b/api/src/membership/models.py @@ -131,6 +131,22 @@ def __repr__(self) -> str: ) +class GroupDoorAccess(Base): + __tablename__ = "membership_group_door_access" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False, autoincrement=True) + group_id: Mapped[int] = mapped_column(Integer, ForeignKey("membership_groups.group_id")) + accessy_asset_publication_guid: Mapped[str] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] + + group: Mapped[Group] = relationship(Group, backref="door_access", cascade_backrefs=False) + + def __repr__(self) -> str: + return f"GroupDoorAccess(group_id={self.group_id}, door_guid={self.accessy_asset_publication_guid})" + + class Permission(Base): __tablename__ = "membership_permissions" diff --git a/api/src/membership/views.py b/api/src/membership/views.py index 3b731f01f..f8ff5460a 100644 --- a/api/src/membership/views.py +++ b/api/src/membership/views.py @@ -35,7 +35,7 @@ get_members_and_membership, get_membership_summary, ) -from membership.models import Group, Key, Member, Permission, Span, group_permission, member_group +from membership.models import Group, GroupDoorAccess, Key, Member, Permission, Span, group_permission, member_group member_entity = MemberEntity( Member, @@ -83,6 +83,9 @@ expand_fields={"member": ExpandField(Key.member, [Member.member_number, Member.firstname, Member.lastname])}, ) +group_door_access_entity = Entity( + GroupDoorAccess, +) service.entity_routes( path="/member", @@ -197,6 +200,15 @@ def all_with_membership(entity_id=None): permission_remove=PERMISSION_MANAGE, ) +service.related_entity_routes( + path="/group//door_access_permissions", + entity=group_door_access_entity, + relation=OrmSingeRelation("door_access_permissions", "group_id"), + permission_list=PERMISSION_VIEW, + permission_add=None, + permission_remove=None, +) + service.entity_routes( path="/permission", entity=permission_entity, @@ -228,3 +240,13 @@ def all_with_membership(entity_id=None): permission_update=KEYS_EDIT, permission_delete=KEYS_EDIT, ) + +service.entity_routes( + path="/group_door_access_permissions", + entity=group_door_access_entity, + permission_list=GROUP_VIEW, + permission_read=GROUP_VIEW, + permission_create=GROUP_EDIT, + permission_update=GROUP_EDIT, + permission_delete=GROUP_EDIT, +) diff --git a/api/src/migrations/0033_group_accessy_permissions.sql b/api/src/migrations/0033_group_accessy_permissions.sql new file mode 100644 index 000000000..39d456027 --- /dev/null +++ b/api/src/migrations/0033_group_accessy_permissions.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `membership_group_door_access` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `group_id` int(10) unsigned NOT NULL, + `accessy_asset_publication_guid` VARCHAR(255) NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `membership_group_doors_group_id_index` (`group_id`), + CONSTRAINT `membership_groups_foreign` FOREIGN KEY (`group_id`) REFERENCES `membership_groups` (`group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/api/src/multiaccessy/accessy.py b/api/src/multiaccessy/accessy.py index 72d363e76..b61d9382b 100644 --- a/api/src/multiaccessy/accessy.py +++ b/api/src/multiaccessy/accessy.py @@ -6,11 +6,12 @@ from logging import getLogger from random import random from time import sleep -from typing import Any, List, Optional, Union +from typing import Any, List, Optional, Tuple, Union from urllib.parse import urlparse import requests from dataclasses_json import DataClassJsonMixin +from flask import Response from membership.models import Member from NamedAtomicLock import NamedAtomicLock from service.config import config @@ -24,6 +25,10 @@ class AccessyError(RuntimeError): pass +class ModificationsDisabledError(Exception): + pass + + PHONE = str UUID = str # The UUID used by Accessy is a 16 byte hexadecimal number formatted as 01234567-abcd-abcd-abcd-0123456789ab (i.e. grouped by 4, 2, 2, 2, 6 with dashes in between) MSISDN = str # Standardized number of the form +46123123456 @@ -191,6 +196,68 @@ class AccessyUserMembership(DataClassJsonMixin): roles: list[str] +@dataclass +class AccessyAccessPermissionGroup(DataClassJsonMixin): + id: UUID + name: str + description: str + constraints: dict[str, Any] + childConstraints: bool + + +@dataclass +class AccessyAccessPermission(DataClassJsonMixin): + id: UUID + createdAt: str + membership: UUID + accessPermissionGroup: UUID + assetPublication: UUID + delegatorComment: str + requesterComment: str + + +@dataclass +class AccessyImage(DataClassJsonMixin): + id: UUID + imageId: UUID + thumbnailId: UUID + size: int + width: int + height: int + + +@dataclass +class AccessyAsset(DataClassJsonMixin): + id: UUID + createdAt: str + updatedAt: str + name: str + description: str + position2d: dict[str, Any] # Placeholder for 2D position data + additionalDeviceAuthentication: str # e.g., "NONE" + address: dict[str, Any] # Placeholder for address data + images: List[AccessyImage] # List of image URLs or identifiers + category: str # e.g., "CHARGING_STATION" + allowGuestAccess: bool + status: str # e.g., "OK" + + +@dataclass +class AccessyAssetPublication(DataClassJsonMixin): + id: UUID + fromOrganization: UUID + toOrganization: UUID + listingState: str # e.g., "UNLISTED" + possibleListingStates: list[str] # e.g., ["UNLISTED"] + fromOrganizationName: str + + +@dataclass +class AccessyAssetWithPublication(DataClassJsonMixin): + asset: AccessyAsset + publication: AccessyAssetPublication + + class AccessySession: def __init__(self) -> None: self.session_token: str | None = None @@ -198,6 +265,7 @@ def __init__(self) -> None: self._organization_id: str | None = None self._all_webhooks: Optional[List[AccessyWebhook]] = None self._accessy_id_to_member_id_cache: dict[str, int] = {} + self._accessy_asset_id_to_publication_cache: dict[UUID, AccessyAssetPublication] = {} ################# # Class methods @@ -221,6 +289,13 @@ def is_env_configured() -> bool: # Public methods ################# + def get_image_blob(self, image_id: str) -> Response: + self.__ensure_token() + r = requests.request( + "GET", ACCESSY_URL + f"/fs/download/{image_id}", headers={"Authorization": f"Bearer {self.session_token}"} + ) + return Response(r.content, headers=r.headers, status=r.status_code) + def get_member_id_from_accessy_id(self, accessy_id: UUID) -> Optional[int]: if accessy_id in self._accessy_id_to_member_id_cache: return self._accessy_id_to_member_id_cache[accessy_id] @@ -661,6 +736,94 @@ def register_webhook(self, destinationURL: str, eventTypes: list[AccessyWebhookE f"Registered Accessy webhook at {destinationURL}. There are currently {len(self._all_webhooks)} webhooks: {self._all_webhooks}" ) + def get_assets(self) -> list[AccessyAsset]: + """Get all assets in the organization.""" + return [ + AccessyAsset.from_dict(d) + for d in self._get_json_paginated(f"/asset/admin/organization/{self.organization_id()}/asset") + ] + + def get_asset_publication_from_asset_id(self, asset_id: UUID) -> AccessyAssetPublication: + """Get the asset publication for a specific asset ID.""" + if asset_id in self._accessy_asset_id_to_publication_cache: + return self._accessy_asset_id_to_publication_cache[asset_id] + + data = self._get(f"/asset/admin/organization/{self.organization_id()}/asset/{asset_id}/asset-publication") + publication = AccessyAssetPublication.from_dict(data) + self._accessy_asset_id_to_publication_cache[asset_id] = publication + return publication + + def get_asset_publications(self) -> list[AccessyAssetWithPublication]: + """Get all asset publications in the organization.""" + assets = self.get_assets() + return [ + AccessyAssetWithPublication( + asset, + self.get_asset_publication_from_asset_id(asset.id), + ) + for asset in assets + ] + + def create_access_permission_group(self, name: str, description: str) -> AccessyAccessPermissionGroup: + """Create a new access permission group.""" + if not ACCESSY_DO_MODIFY: + raise ModificationsDisabledError() + data = self.__post( + f"/asset/admin/organization/{self.organization_id()}/access-permission-group", + json={ + "name": name, + "description": description, + "constraints": {}, + "childConstraints": False, + }, + ) + return AccessyAccessPermissionGroup.from_dict(data) + + def delete_access_permission_group(self, group_id: UUID) -> None: + """Delete an access permission group.""" + self.__delete( + f"/asset/admin/access-permission-group/{group_id}", + err_msg=f"Failed to delete access permission group with ID {group_id}", + ) + + def get_access_permission_groups(self) -> list[AccessyAccessPermissionGroup]: + """Get all access permission groups for the organization.""" + return [ + AccessyAccessPermissionGroup.from_dict(d) + for d in self._get_json_paginated( + f"/asset/admin/organization/{self.organization_id()}/access-permission-group" + ) + ] + + def delete_access_permission(self, permission_id: UUID) -> None: + """Delete an access permission.""" + self.__delete( + f"/asset/admin/access-permission/{permission_id}", + err_msg=f"Failed to delete access permission with ID {permission_id}", + ) + + def create_access_permission_for_group( + self, + asset_permission_group_id: UUID, + asset_publication_id: UUID, + ) -> AccessyAccessPermission: + """Create a new access permission for a specific group.""" + data = self.__post( + f"/asset/admin/access-permission-group/{asset_permission_group_id}/access-permission", + json={ + "assetPublication": asset_publication_id, + "constraints": {}, + }, + ) + return AccessyAccessPermission.from_dict(data) + + def get_access_permissions(self, group_id: UUID) -> list[AccessyAccessPermission]: + """Get all access permissions for a specific access permission group.""" + return [ + AccessyAccessPermission.from_dict(d) + for d in self._get_json_paginated(f"/asset/admin/access-permission-group/{group_id}/access-permission") + ] + accessy_session = AccessySession() if AccessySession.is_env_configured() else None @@ -669,8 +832,11 @@ def register_webhook(self, destinationURL: str, eventTypes: list[AccessyWebhookE def register_accessy_webhook() -> bool: - HOST_BACKEND: str = config.get("HOST_BACKEND").strip() - webhook_url: str = f"{HOST_BACKEND}/accessy/event" + HOST_BACKEND: str | None = config.get("HOST_BACKEND") + if HOST_BACKEND is None: + logger.warning("HOST_BACKEND is not configured. Skipping accessy webhook registration.") + return False + webhook_url: str = f"{HOST_BACKEND.strip()}/accessy/event" if accessy_session is None: logger.warning(f"Accessy not configured. Skipping accessy webhook registration.") return False diff --git a/api/src/multiaccessy/sync.py b/api/src/multiaccessy/sync.py index a971a65f6..be05fb147 100644 --- a/api/src/multiaccessy/sync.py +++ b/api/src/multiaccessy/sync.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 +import itertools from dataclasses import dataclass, field from datetime import date, timedelta from logging import getLogger -from typing import Dict, List, Optional +from typing import Callable, Dict, Generic, List, Optional, Set, Tuple, TypeVar from membership.membership import get_members_and_membership, get_membership_summaries, get_membership_summary -from membership.models import Member, Span +from membership.models import Group, GroupDoorAccess, Member, Span from service.db import db_session +from sqlalchemy import select from sqlalchemy.orm import contains_eager from multiaccessy.accessy import ( @@ -14,13 +16,22 @@ ACCESSY_SPECIAL_LABACCESS_GROUP, PHONE, AccessyMember, + ModificationsDisabledError, accessy_session, ) logger = getLogger("makeradmin") +ACCESSY_GROUP_PREFIX = "makeradmin_group_" -def get_wanted_access(today: date, member_id: Optional[int] = None) -> dict[PHONE, AccessyMember]: + +def makeradmin_group_name_to_accessy_group_name(group_name: str) -> str: + return f"{ACCESSY_GROUP_PREFIX}{group_name.lower().replace(' ', '_')}" + + +def get_wanted_access( + today: date, group_ids_to_accessy_guids: Dict[int, str], member_id: Optional[int] = None +) -> dict[PHONE, AccessyMember]: if member_id is not None: member = db_session.query(Member).get(member_id) if member is None: @@ -30,18 +41,34 @@ def get_wanted_access(today: date, member_id: Optional[int] = None) -> dict[PHON else: members, summaries = get_members_and_membership(at_date=today) + groups_by_member_res = db_session.execute( + select(Member.member_id, Group.group_id) + .join(Member.groups) + .where(Member.deleted_at == None, Group.deleted_at == None) + ).t + member_ids_to_accessy_group_guids: Dict[int, Set[str]] = dict() + for member_id, group_id in groups_by_member_res: + if member_id not in member_ids_to_accessy_group_guids: + member_ids_to_accessy_group_guids[member_id] = set() + if group_id not in group_ids_to_accessy_guids: + # This can happen if the accessy group could not be created, because accessy modifications are disabled + continue + group_guid = group_ids_to_accessy_guids[group_id] + member_ids_to_accessy_group_guids[member_id].add(group_guid) + + for member, membership in zip(members, summaries): + if member.member_id not in member_ids_to_accessy_group_guids: + member_ids_to_accessy_group_guids[member.member_id] = set() + if membership.special_labaccess_active: + member_ids_to_accessy_group_guids[member.member_id].add(ACCESSY_SPECIAL_LABACCESS_GROUP) + if membership.labaccess_active: + member_ids_to_accessy_group_guids[member.member_id].add(ACCESSY_LABACCESS_GROUP) + return { member.phone: AccessyMember( phone=member.phone, name=f"{member.firstname} {member.lastname}", # Never actually used - groups={ - group - for group, enabled in { - ACCESSY_SPECIAL_LABACCESS_GROUP: membership.special_labaccess_active, - ACCESSY_LABACCESS_GROUP: membership.labaccess_active, - }.items() - if enabled - }, + groups=member_ids_to_accessy_group_guids[member.member_id], member_id=member.member_id, member_number=member.member_number, ) @@ -56,48 +83,133 @@ class GroupOp: group: str +TItem = TypeVar("TItem") +TAttribute = TypeVar("TAttribute") + + @dataclass -class Diff: - invites: List[AccessyMember] = field(default_factory=list) - group_adds: List[GroupOp] = field(default_factory=list) - group_removes: List[GroupOp] = field(default_factory=list) - org_removes: List[AccessyMember] = field(default_factory=list) +class Diff(Generic[TItem, TAttribute]): + item_adds: List[TItem] = field(default_factory=list) + item_removes: List[TItem] = field(default_factory=list) + attribute_adds: List[Tuple[TItem, TAttribute]] = field(default_factory=list) + attribute_removes: List[Tuple[TItem, TAttribute]] = field(default_factory=list) -def calculate_diff(actual_members: Dict[str, AccessyMember], wanted_members: Dict[str, AccessyMember]) -> Diff: - diff = Diff() +# Diffs a set of items that have attributes +def calculate_diff( + actual: Dict[TItem, Set[TAttribute]], + wanted: Dict[TItem, Set[TAttribute]], + skip_attributes_for_added_items: bool, +) -> Diff[TItem, TAttribute]: + diff: Diff[TItem, TAttribute] = Diff() - if ACCESSY_LABACCESS_GROUP is None or ACCESSY_SPECIAL_LABACCESS_GROUP is None: - # We cannot perform diffing if - logger.warning( - "ACCESSY_LABACCESS_GROUP and/or ACCESSY_SPECIAL_LABACCESS_GROUP not configured, there will be no accessy diff" - ) - return diff + groups_wanted = wanted.keys() + groups_actual = actual.keys() # Missing in Accessy, invite needed: - for wanted in (wanted for phone, wanted in wanted_members.items() if phone not in actual_members): - diff.invites.append(wanted) + for w in groups_wanted - groups_actual: + diff.item_adds.append(w) # Shouldn't have any access, remove from org needed: - for actual in (actual for phone, actual in actual_members.items() if phone not in wanted_members): - diff.org_removes.append(actual) + for a in groups_actual - groups_wanted: + diff.item_removes.append(a) # Already exists in accessy and should have access, but could be wrong groups: - for phone, wanted in wanted_members.items(): - actual_m = actual_members.get(phone) + for group, wanted_elements in wanted.items(): + actual_elements = actual.get(group) - if not actual_m: - continue + if actual_elements is None: + if skip_attributes_for_added_items: + continue + else: + actual_elements = set() - for group in wanted.groups - actual_m.groups: - diff.group_adds.append(GroupOp(actual_m, group)) + for element in wanted_elements - actual_elements: + diff.attribute_adds.append((group, element)) - for group in actual_m.groups - wanted.groups: - diff.group_removes.append(GroupOp(actual_m, group)) + for element in actual_elements - wanted_elements: + diff.attribute_removes.append((group, element)) return diff +def sync_groups(today: date) -> Dict[int, str]: + assert accessy_session is not None, "Accessy session must be initialized before syncing groups" + + if ACCESSY_LABACCESS_GROUP is None or ACCESSY_SPECIAL_LABACCESS_GROUP is None: + logger.warning( + "ACCESSY_LABACCESS_GROUP and/or ACCESSY_SPECIAL_LABACCESS_GROUP not configured, there will be no accessy diff" + ) + + existing_groups = [ + g for g in accessy_session.get_access_permission_groups() if g.name.startswith(ACCESSY_GROUP_PREFIX) + ] + + existing_accesses = [accessy_session.get_access_permissions(g.id) for g in existing_groups] + + makeradmin_groups = db_session.scalars(select(Group).where(Group.deleted_at == None)).all() + group_door_accesses = db_session.scalars(select(GroupDoorAccess).where(GroupDoorAccess.deleted_at == None)).all() + + wanted_groups = { + makeradmin_group_name_to_accessy_group_name(group.name): { + access.accessy_asset_publication_guid for access in group_door_accesses if access.group_id == group.group_id + } + for group in makeradmin_groups + } + # Remove groups that don't have any accesses, to keep things tidy + wanted_groups = {name: accesses for name, accesses in wanted_groups.items() if len(accesses) > 0} + + diff = calculate_diff( + {g.name: {a.assetPublication for a in accesses} for g, accesses in zip(existing_groups, existing_accesses)}, + wanted_groups, + False, + ) + + for name in diff.item_adds: + group = next( + (g for g in makeradmin_groups if makeradmin_group_name_to_accessy_group_name(g.name) == name), None + ) + assert group is not None + logger.info(f"Creating Accessy group: {name}") + try: + new_g = accessy_session.create_access_permission_group( + name, "Makeradmin group for " + group.name + ". Automatically created and managed by Makeradmin." + ) + existing_groups.append(new_g) + except ModificationsDisabledError: + pass + + existing_groups_lookup = {g.name: g for g in existing_groups} + + for name in diff.item_removes: + permission_group = existing_groups_lookup[name] + logger.info(f"Removing Accessy group: {name}") + accessy_session.delete_access_permission_group(permission_group.id) + + for name, asset_publication_id in diff.attribute_adds: + if name not in existing_groups_lookup: + logger.warning(f"Accessy group {name} not found, skipping access addition") + # This can happen if accessy modifications have been disabled + continue + permission_group = existing_groups_lookup[name] + logger.info(f"Adding access to Accessy group: {name} with access: {asset_publication_id}") + accessy_session.create_access_permission_for_group(permission_group.id, asset_publication_id) + + for name, asset_publication_id in diff.attribute_removes: + group_index = next((i for i, g in enumerate(existing_groups) if g.name == name), None) + assert group_index is not None + access = next((a for a in existing_accesses[group_index] if a.assetPublication == asset_publication_id), None) + assert access is not None + logger.info(f"Removing access from Accessy group: {name} with access: {asset_publication_id}") + accessy_session.delete_access_permission(access.id) + + return { + g.group_id: existing_groups_lookup[makeradmin_group_name_to_accessy_group_name(g.name)].id + for g in makeradmin_groups + if makeradmin_group_name_to_accessy_group_name(g.name) in existing_groups_lookup + } + + def sync(today: Optional[date] = None, member_id: Optional[int] = None) -> None: if accessy_session is None: logger.info(f"accessy sync skipped, accessy not configured.") @@ -119,10 +231,13 @@ def sync(today: Optional[date] = None, member_id: Optional[int] = None) -> None: else: actual_members = accessy_session.get_all_members() + group_ids_to_accessy_guids = sync_groups(today) pending_invites = accessy_session.get_pending_invitations(after_date=today - timedelta(days=7)) - wanted_members = get_wanted_access(today, member_id=member_id) + wanted_members = get_wanted_access( + today, member_id=member_id, group_ids_to_accessy_guids=group_ids_to_accessy_guids + ) - actual_members_by_phone = {} + actual_members_by_phone: Dict[str, AccessyMember] = {} members_to_discard: List[AccessyMember] = [] for m in actual_members: if m.phone: @@ -131,25 +246,33 @@ def sync(today: Optional[date] = None, member_id: Optional[int] = None) -> None: # Members with no phone numbers are probably accounts that have gotten reset members_to_discard.append(m) - diff = calculate_diff(actual_members_by_phone, wanted_members) + diff = calculate_diff( + {phone: m.groups for (phone, m) in actual_members_by_phone.items()}, + {phone: m.groups for (phone, m) in wanted_members.items()}, + True, + ) - for accessy_member in diff.invites: - assert accessy_member.phone is not None - if accessy_member.phone in pending_invites: + for phone in diff.item_adds: + accessy_member = wanted_members[phone] + assert accessy_member.phone is not None, "Accessy member phone must not be None" + if phone in pending_invites: logger.info(f"accessy sync skipping, invite already pending: {accessy_member}") continue logger.info(f"accessy sync inviting: {accessy_member}") accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups) - for op in diff.group_adds: - logger.info(f"accessy sync adding to group: {op}") - accessy_session.add_to_group(op.member, op.group) + for phone, group in diff.attribute_adds: + accessy_member = actual_members_by_phone[phone] + logger.info(f"accessy sync adding to group: {(accessy_member.name, group)}") + accessy_session.add_to_group(accessy_member, group) - for op in diff.group_removes: - logger.info(f"accessy sync removing from group: {op}") - accessy_session.remove_from_group(op.member, op.group) + for phone, group in diff.attribute_removes: + accessy_member = actual_members_by_phone[phone] + logger.info(f"accessy sync removing from group: {(accessy_member.name, group)}") + accessy_session.remove_from_group(accessy_member, group) - for accessy_member in diff.org_removes: + for phone in diff.item_removes: + accessy_member = actual_members_by_phone[phone] logger.info(f"accessy sync removing from org: {accessy_member}") accessy_session.remove_from_org(accessy_member) diff --git a/api/src/multiaccessy/views.py b/api/src/multiaccessy/views.py index 92a8e3f6f..47ce9a48a 100644 --- a/api/src/multiaccessy/views.py +++ b/api/src/multiaccessy/views.py @@ -1,18 +1,26 @@ from dataclasses import dataclass from datetime import date, datetime from logging import getLogger -from typing import Any, Optional, cast +from typing import Any, Optional, Tuple, cast from dataclasses_json import DataClassJsonMixin -from flask import request -from service.api_definition import POST, PUBLIC +from flask import Response, request +from service.api_definition import GET, GROUP_VIEW, PERMISSION_MANAGE, POST, PUBLIC from service.db import db_session from service.error import UnprocessableEntity from multiaccessy import service from multiaccessy.models import PhysicalAccessEntry -from .accessy import UUID, AccessyWebhookEventType, accessy_session +from . import sync as syncer +from .accessy import ( + UUID, + AccessyAsset, + AccessyAssetPublication, + AccessyAssetWithPublication, + AccessyWebhookEventType, + accessy_session, +) logger = getLogger("makeradmin") @@ -212,7 +220,7 @@ def handle_event(event: AccessyWebhookEvent) -> None: @service.route("/event", method=POST, permission=PUBLIC) -def accessy_webhook() -> None: +def accessy_webhook() -> str: if accessy_session is None: raise UnprocessableEntity("Accessy session not initialized") @@ -227,6 +235,31 @@ def accessy_webhook() -> None: if event is not None: logger.info(f"Received accessy event: {event.to_dict()}") handle_event(event) - return None + return "ok" else: raise UnprocessableEntity("Invalid signature") + + +@service.route("/assets", method=GET, permission=GROUP_VIEW) +def get_assets() -> list[AccessyAssetWithPublication]: + if accessy_session is None: + raise UnprocessableEntity("Accessy session not initialized") + + return accessy_session.get_asset_publications() + + +@service.route("/sync", method=POST, permission=PERMISSION_MANAGE) +def sync() -> str: + syncer.sync() + return "ok" + + +@service.route("/image/", method=GET, permission=PUBLIC) +def image(image_id: str) -> Response: + """ + Proxy an image from Accessy. + """ + if accessy_session is None: + raise UnprocessableEntity("Accessy session not initialized") + + return accessy_session.get_image_blob(image_id) diff --git a/api/src/service/internal_service.py b/api/src/service/internal_service.py index 0708b7cc8..b308e30da 100644 --- a/api/src/service/internal_service.py +++ b/api/src/service/internal_service.py @@ -3,7 +3,7 @@ from typing import Any, Callable, List, Optional, Tuple import pymysql -from flask import Blueprint, g, jsonify +from flask import Blueprint, Response, g, jsonify from flask import typing as ft from pymysql.constants.ER import BAD_NULL_ERROR, DUP_ENTRY from sqlalchemy.exc import IntegrityError @@ -89,7 +89,9 @@ def view_wrapper(*args, **kwargs): data = f(*args, **kwargs) - if flat_return: + if isinstance(data, Response): + result = data + elif flat_return: result = jsonify({**data, "status": status}), code else: result = jsonify({"status": status, "data": data}), code diff --git a/api/src/systest/api/accessy_sync_test.py b/api/src/systest/api/accessy_sync_test.py index 0c0a1937d..46d99c6ac 100644 --- a/api/src/systest/api/accessy_sync_test.py +++ b/api/src/systest/api/accessy_sync_test.py @@ -4,6 +4,7 @@ from membership.models import Span from multiaccessy.accessy import ACCESSY_LABACCESS_GROUP, ACCESSY_SPECIAL_LABACCESS_GROUP, AccessyMember from multiaccessy.sync import Diff, GroupOp, calculate_diff, get_wanted_access +from service.db import db_session from test_aid.obj import random_phone_number from test_aid.systest_base import ApiTest @@ -19,24 +20,39 @@ def test_diff(self) -> None: m1_special = AccessyMember(phone=m1_phone, groups={ACCESSY_SPECIAL_LABACCESS_GROUP}) m1_none = AccessyMember(phone=m1_phone, groups=set()) - diff = calculate_diff(wanted_members={m1_phone: m1_both}, actual_members={}) - self.assertEqual(Diff(invites=[m1_both]), diff) + diff = calculate_diff(wanted={m1_phone: m1_both.groups}, actual={}, skip_attributes_for_added_items=True) + self.assertEqual(Diff(item_adds=[m1_phone]), diff) - diff = calculate_diff(wanted_members={m1_phone: m1_lab}, actual_members={m1_phone: m1_both}) - self.assertEqual([GroupOp(m1_both, ACCESSY_SPECIAL_LABACCESS_GROUP)], diff.group_removes) - self.assertEqual(Diff(group_removes=[GroupOp(m1_both, ACCESSY_SPECIAL_LABACCESS_GROUP)]), diff) + diff = calculate_diff(wanted={m1_phone: m1_both.groups}, actual={}, skip_attributes_for_added_items=False) + self.assertEqual( + Diff( + item_adds=[m1_phone], + attribute_adds=[(m1_phone, ACCESSY_SPECIAL_LABACCESS_GROUP), (m1_phone, ACCESSY_LABACCESS_GROUP)], + ), + diff, + ) - diff = calculate_diff(wanted_members={m1_phone: m1_lab}, actual_members={m1_phone: m1_none}) - self.assertEqual(Diff(group_adds=[GroupOp(m1_none, ACCESSY_LABACCESS_GROUP)]), diff) + diff = calculate_diff( + wanted={m1_phone: m1_lab.groups}, actual={m1_phone: m1_both.groups}, skip_attributes_for_added_items=True + ) + self.assertEqual([(m1_both.phone, ACCESSY_SPECIAL_LABACCESS_GROUP)], diff.attribute_removes) + self.assertEqual(Diff(attribute_removes=[(m1_both.phone, ACCESSY_SPECIAL_LABACCESS_GROUP)]), diff) + + diff = calculate_diff( + wanted={m1_phone: m1_lab.groups}, actual={m1_phone: m1_none.groups}, skip_attributes_for_added_items=True + ) + self.assertEqual(Diff(attribute_adds=[(m1_none.phone, ACCESSY_LABACCESS_GROUP)]), diff) - diff = calculate_diff(wanted_members={}, actual_members={m1_phone: m1_none}) - self.assertEqual(Diff(org_removes=[m1_none]), diff) + diff = calculate_diff(wanted={}, actual={m1_phone: m1_none.groups}, skip_attributes_for_added_items=True) + self.assertEqual(Diff(item_removes=[m1_none.phone]), diff) - diff = calculate_diff(wanted_members={m1_phone: m1_lab}, actual_members={m1_phone: m1_special}) + diff = calculate_diff( + wanted={m1_phone: m1_lab.groups}, actual={m1_phone: m1_special.groups}, skip_attributes_for_added_items=True + ) self.assertEqual( Diff( - group_adds=[GroupOp(m1_special, ACCESSY_LABACCESS_GROUP)], - group_removes=[GroupOp(m1_special, ACCESSY_SPECIAL_LABACCESS_GROUP)], + attribute_adds=[(m1_special.phone, ACCESSY_LABACCESS_GROUP)], + attribute_removes=[(m1_special.phone, ACCESSY_SPECIAL_LABACCESS_GROUP)], ), diff, ) @@ -125,8 +141,17 @@ def test_get_wanted_access(self) -> None: self.db.create_span( startdate=self.date(0), enddate=self.date(0), type=Span.LABACCESS, member=not_included_because_deleted ) + group = self.db.create_group(name="Test Group") + TEST_GROUP_GUID = "test-group-guid" + group.members.append(included_because_both_kinds_of_spans) + db_session.flush() - wanted = get_wanted_access(self.date()) + wanted = get_wanted_access( + self.date(), + group_ids_to_accessy_guids={ + group.group_id: TEST_GROUP_GUID, + }, + ) wanted_ids = {m.member_id for m in wanted.values()} @@ -139,6 +164,7 @@ def test_get_wanted_access(self) -> None: self.assertIn(included_because_labaccess_span.phone, wanted) assert included_because_labaccess_span.phone is not None self.assertIn(ACCESSY_LABACCESS_GROUP, wanted[included_because_labaccess_span.phone].groups) + self.assertNotIn(TEST_GROUP_GUID, wanted[included_because_labaccess_span.phone].groups) self.assertIn(included_because_both_kinds_of_spans.member_id, wanted_ids) self.assertIn(included_because_both_kinds_of_spans.phone, wanted) @@ -146,6 +172,7 @@ def test_get_wanted_access(self) -> None: assert included_because_both_kinds_of_spans.phone is not None self.assertIn(ACCESSY_LABACCESS_GROUP, wanted[included_because_both_kinds_of_spans.phone].groups) self.assertIn(ACCESSY_SPECIAL_LABACCESS_GROUP, wanted[included_because_both_kinds_of_spans.phone].groups) + self.assertIn(TEST_GROUP_GUID, wanted[included_because_both_kinds_of_spans.phone].groups) self.assertNotIn(not_included_because_spans_outside_today.member_id, wanted_ids) self.assertNotIn(not_included_because_spans_outside_today.phone, wanted) From 41712613e9bf1c3ed76dc885fb03f8160a6bf237 Mon Sep 17 00:00:00 2001 From: Aron Granberg Date: Tue, 17 Jun 2025 16:12:37 +0200 Subject: [PATCH 2/6] Minor cleanup. --- admin/src/Membership/GroupBoxDoorAccess.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/admin/src/Membership/GroupBoxDoorAccess.tsx b/admin/src/Membership/GroupBoxDoorAccess.tsx index dd56d34a2..40b8b8291 100644 --- a/admin/src/Membership/GroupBoxDoorAccess.tsx +++ b/admin/src/Membership/GroupBoxDoorAccess.tsx @@ -90,7 +90,6 @@ const GroupBoxDoorAccess = () => { accessy_asset_publication_guid: item.publication.id, }); await access.save(); - // await collection.add(access); setSelectedOption(null); await collection.fetch(); await post({ @@ -163,7 +162,9 @@ const GroupBoxDoorAccess = () => { className="accessy-asset-img-thumbnail" src={ config.apiBasePath + - `/accessy/image/${p.asset.images[0].thumbnailId}` + `/accessy/image/${ + p.asset.images[0]!.thumbnailId + }` } /> ) : null} From 5af8984287df35abb1cf8143f850cbb5fe152b05 Mon Sep 17 00:00:00 2001 From: Aron Granberg Date: Tue, 17 Jun 2025 16:18:49 +0200 Subject: [PATCH 3/6] Clarifications --- admin/src/Membership/GroupBoxDoorAccess.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/admin/src/Membership/GroupBoxDoorAccess.tsx b/admin/src/Membership/GroupBoxDoorAccess.tsx index 40b8b8291..dc66742b0 100644 --- a/admin/src/Membership/GroupBoxDoorAccess.tsx +++ b/admin/src/Membership/GroupBoxDoorAccess.tsx @@ -143,6 +143,10 @@ const GroupBoxDoorAccess = () => { return (
+

+ All members of this group will get access to the following + Accessy assets if they have active labaccess. +

From 2e94164c18df8d9a148c7eaec00be0cf60284a66 Mon Sep 17 00:00:00 2001 From: Aron Granberg Date: Tue, 17 Jun 2025 16:37:12 +0200 Subject: [PATCH 4/6] Fix test. --- api/src/systest/api/accessy_sync_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/src/systest/api/accessy_sync_test.py b/api/src/systest/api/accessy_sync_test.py index 46d99c6ac..2d0077610 100644 --- a/api/src/systest/api/accessy_sync_test.py +++ b/api/src/systest/api/accessy_sync_test.py @@ -24,10 +24,13 @@ def test_diff(self) -> None: self.assertEqual(Diff(item_adds=[m1_phone]), diff) diff = calculate_diff(wanted={m1_phone: m1_both.groups}, actual={}, skip_attributes_for_added_items=False) + diff.attribute_adds.sort() self.assertEqual( Diff( item_adds=[m1_phone], - attribute_adds=[(m1_phone, ACCESSY_SPECIAL_LABACCESS_GROUP), (m1_phone, ACCESSY_LABACCESS_GROUP)], + attribute_adds=sorted( + [(m1_phone, ACCESSY_SPECIAL_LABACCESS_GROUP), (m1_phone, ACCESSY_LABACCESS_GROUP)] + ), ), diff, ) From b98742dff2b0728c7359c368fe8a957be8e1f973 Mon Sep 17 00:00:00 2001 From: Aron Granberg Date: Tue, 17 Jun 2025 16:37:17 +0200 Subject: [PATCH 5/6] Address review comments. --- api/src/membership/models.py | 2 +- api/src/migrations/0033_group_accessy_permissions.sql | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/membership/models.py b/api/src/membership/models.py index 40690e601..fa01abbdc 100644 --- a/api/src/membership/models.py +++ b/api/src/membership/models.py @@ -136,7 +136,7 @@ class GroupDoorAccess(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False, autoincrement=True) group_id: Mapped[int] = mapped_column(Integer, ForeignKey("membership_groups.group_id")) - accessy_asset_publication_guid: Mapped[str] = mapped_column(String(64)) + accessy_asset_publication_guid: Mapped[str] = mapped_column(String(255)) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) deleted_at: Mapped[Optional[datetime]] diff --git a/api/src/migrations/0033_group_accessy_permissions.sql b/api/src/migrations/0033_group_accessy_permissions.sql index 39d456027..d1a38c7f2 100644 --- a/api/src/migrations/0033_group_accessy_permissions.sql +++ b/api/src/migrations/0033_group_accessy_permissions.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS `membership_group_door_access` ( `deleted_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `membership_group_doors_group_id_index` (`group_id`), - CONSTRAINT `membership_groups_foreign` FOREIGN KEY (`group_id`) REFERENCES `membership_groups` (`group_id`) + UNIQUE KEY uniq_group_door (group_id, accessy_asset_publication_guid), + CONSTRAINT `membership_groups_foreign` FOREIGN KEY (`group_id`) REFERENCES `membership_groups` (`group_id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; From cefd4d20f165973351de4cd3345726c9fcc2eac7 Mon Sep 17 00:00:00 2001 From: Aron Granberg Date: Wed, 18 Jun 2025 10:28:39 +0200 Subject: [PATCH 6/6] Fix crash when accessy is not configured. --- admin/src/Components/CollectionTable.js | 7 +++++-- admin/src/Membership/GroupBoxDoorAccess.tsx | 12 +++++++++++- api/src/multiaccessy/views.py | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/admin/src/Components/CollectionTable.js b/admin/src/Components/CollectionTable.js index 1605da448..4e347804b 100644 --- a/admin/src/Components/CollectionTable.js +++ b/admin/src/Components/CollectionTable.js @@ -25,8 +25,11 @@ const CollectionTable = (props) => { emptyMessage, className, onPageNav, + loading: loadingProp, } = props; + const effectivelyLoading = loading || loadingProp; + useEffect(() => { const unsubscribe = collection.subscribe(({ page: p, items: i }) => { setPage(p); @@ -170,7 +173,7 @@ const CollectionTable = (props) => { @@ -178,7 +181,7 @@ const CollectionTable = (props) => { {rows}
- {loading ? ( + {effectivelyLoading ? (
diff --git a/admin/src/Membership/GroupBoxDoorAccess.tsx b/admin/src/Membership/GroupBoxDoorAccess.tsx index dc66742b0..f0db2337a 100644 --- a/admin/src/Membership/GroupBoxDoorAccess.tsx +++ b/admin/src/Membership/GroupBoxDoorAccess.tsx @@ -43,6 +43,7 @@ const GroupBoxDoorAccess = () => { useState(null); const [options, setOptions] = useState([]); const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); const filteredOptions = useMemo(() => { return options.filter((option) => { return !items.some( @@ -67,6 +68,7 @@ const GroupBoxDoorAccess = () => { useEffect(() => { get({ url: "/accessy/assets" }).then(({ data }) => { setOptions(data); + setLoading(false); }); }, []); @@ -119,7 +121,9 @@ const GroupBoxDoorAccess = () => { ))} - {asset !== undefined ? asset.asset.name : "Unknown Asset"} + {asset !== undefined + ? asset.asset.name + : `Unknown Asset - ${item.accessy_asset_publication_guid}`} { )} onChange={(p) => selectOption(p)} isDisabled={!filteredOptions.length} + placeholder={ + !loading && options.length == 0 + ? "Inga Accessy-tillgångar hittades" + : "Välj tillgång" + } />
@@ -186,6 +195,7 @@ const GroupBoxDoorAccess = () => { diff --git a/api/src/multiaccessy/views.py b/api/src/multiaccessy/views.py index 47ce9a48a..e3279ebb6 100644 --- a/api/src/multiaccessy/views.py +++ b/api/src/multiaccessy/views.py @@ -243,7 +243,7 @@ def accessy_webhook() -> str: @service.route("/assets", method=GET, permission=GROUP_VIEW) def get_assets() -> list[AccessyAssetWithPublication]: if accessy_session is None: - raise UnprocessableEntity("Accessy session not initialized") + return [] return accessy_session.get_asset_publications()