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/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/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..f0db2337a
--- /dev/null
+++ b/admin/src/Membership/GroupBoxDoorAccess.tsx
@@ -0,0 +1,207 @@
+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 [loading, setLoading] = useState(true);
+ 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);
+ setLoading(false);
+ });
+ }, []);
+
+ 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();
+ 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 - ${item.accessy_asset_publication_guid}`}
+ |
+
+ {
+ await item.del();
+ await collection.fetch();
+ await post({
+ url: `/accessy/sync`,
+ expectedDataStatus: "ok",
+ });
+ }}
+ className="removebutton"
+ >
+
+
+ |
+
+ );
+ };
+
+ return (
+
+
+
+ All members of this group will get access to the following
+ Accessy assets if they have active labaccess.
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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..fa01abbdc 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(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]]
+
+ 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..d1a38c7f2
--- /dev/null
+++ b/api/src/migrations/0033_group_accessy_permissions.sql
@@ -0,0 +1,12 @@
+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`),
+ 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;
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..e3279ebb6 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:
+ return []
+
+ 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..2d0077610 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,42 @@ 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)
+ diff.attribute_adds.sort()
+ self.assertEqual(
+ Diff(
+ item_adds=[m1_phone],
+ attribute_adds=sorted(
+ [(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 +144,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 +167,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 +175,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)