Still refactoring and unit tests

This commit is contained in:
2025-08-30 22:37:17 +00:00
parent 1d423c5ea2
commit c76a77cb0c
16 changed files with 78 additions and 73 deletions

13
bot.py
View File

@@ -1,16 +1,17 @@
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler
from functools import partial from functools import partial
from config import BotConfig from config import BotConfig
from services import GatesService, AVConnectService, UsersService from src.services import GatesService, AVConnectService, UsersService
from repository import GatesRepository, UsersRepository from src.repository import GatesRepository, UsersRepository
from handlers import * from src.handlers import *
bot_config = BotConfig("lagomareGateKeeperBot") bot_config = BotConfig("lagomareGateKeeperBot")
gates_repository = GatesRepository()
avconnect_service = AVConnectService()
gates_service = GatesService(gates_repository, avconnect_service)
users_repository = UsersRepository() users_repository = UsersRepository()
users_service = UsersService(users_repository) users_service = UsersService(users_repository)
avconnect_service = AVConnectService()
gates_repository = GatesRepository()
gates_service = GatesService(gates_repository, avconnect_service, users_service)
def main(): def main():
app = Application.builder().token(bot_config.token).post_init(partial(post_init, bot_config=bot_config)).build() app = Application.builder().token(bot_config.token).post_init(partial(post_init, bot_config=bot_config)).build()

View File

@@ -1,9 +1,10 @@
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from datetime import datetime from datetime import datetime
from models import Users, Role from src.models import Role
from src.services import UsersService
async def requestaccess(update: Update, context: ContextTypes.DEFAULT_TYPE, users: Users): async def requestaccess(update: Update, context: ContextTypes.DEFAULT_TYPE, users: UsersService):
assert update.effective_user is not None assert update.effective_user is not None
user_id = str(update.effective_user.id) user_id = str(update.effective_user.id)
@@ -13,8 +14,8 @@ async def requestaccess(update: Update, context: ContextTypes.DEFAULT_TYPE, user
await update.callback_query.answer("Only guests can request access.") await update.callback_query.answer("Only guests can request access.")
elif update.message: elif update.message:
return await update.message.reply_text("Only guests can request access.") return await update.message.reply_text("Only guests can request access.")
requester = users.get_fullname(user_id) or users.get_username(user_id) requester = update.effective_user.full_name or update.effective_user.username
text = (f"Access request: {requester} ({user_id}) requests access.\nUse `/grantaccess {user_id} <gate_id|all> YYYY-MM-DDTHH:MM:SSZ` to grant access.") text = f"Access request: {requester} ({user_id}) requests access.\nUse `/grantaccess {user_id} <gate_id|all> YYYY-MM-DDTHH:MM:SSZ` to grant access."
if update.callback_query: if update.callback_query:
await update.callback_query.answer("Your request has been submitted.") await update.callback_query.answer("Your request has been submitted.")
elif update.message: elif update.message:
@@ -26,7 +27,7 @@ async def requestaccess(update: Update, context: ContextTypes.DEFAULT_TYPE, user
except Exception as e: except Exception as e:
print(f"Failed to notify {admin_id} that guest {user_id} requested access: {e}") print(f"Failed to notify {admin_id} that guest {user_id} requested access: {e}")
async def grantaccess(update: Update, context: ContextTypes.DEFAULT_TYPE, users: Users): async def grantaccess(update: Update, context: ContextTypes.DEFAULT_TYPE, users: UsersService):
assert update.effective_user is not None assert update.effective_user is not None
assert update.message is not None assert update.message is not None
assert context.args is not None assert context.args is not None

View File

@@ -1,8 +1,9 @@
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from models import Users, Credential, Role from src.models import Credential, Role
from src.services import UsersService
async def setcredentials(update: Update, context: ContextTypes.DEFAULT_TYPE, users: Users): async def setcredentials(update: Update, context: ContextTypes.DEFAULT_TYPE, users: UsersService):
assert update.effective_user is not None assert update.effective_user is not None
assert update.message is not None assert update.message is not None
assert context.args is not None assert context.args is not None

View File

@@ -1,6 +1,7 @@
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from services import GatesService, UsersService from src.services import GatesService, UsersService
from src.models import Role
async def opengate(update: Update, context: ContextTypes.DEFAULT_TYPE, gates_service: GatesService): async def opengate(update: Update, context: ContextTypes.DEFAULT_TYPE, gates_service: GatesService):
assert update.effective_user is not None assert update.effective_user is not None
@@ -16,7 +17,7 @@ async def opengate(update: Update, context: ContextTypes.DEFAULT_TYPE, gates_ser
return await update.message.reply_text(f"Gate {gate_name} opened!") return await update.message.reply_text(f"Gate {gate_name} opened!")
await update.message.reply_text(f"ERROR: Cannot open gate {gate_name}") await update.message.reply_text(f"ERROR: Cannot open gate {gate_name}")
async def open_gate_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, users_service: UsersService, gates: Gates): async def open_gate_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, users_service: UsersService, gates_service: GatesService):
assert update.effective_user is not None assert update.effective_user is not None
user_id = str(update.effective_user.id) user_id = str(update.effective_user.id)
@@ -28,11 +29,11 @@ async def open_gate_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, use
return await update.message.reply_text("You have no gates to open.") return await update.message.reply_text("You have no gates to open.")
if 'all' in granted_gates: if 'all' in granted_gates:
granted_gates = gates.get_all_enabled() granted_gates = gates_service.get_all_enabled()
# Show a list of available gates as buttons # Show a list of available gates as buttons
keyboard = [] keyboard = []
for gate_id in granted_gates: for gate_id in granted_gates:
gate_name = gates.get_name(gate_id) gate_name = gates_service.get_name(gate_id)
assert gate_name is not None assert gate_name is not None
keyboard.append([InlineKeyboardButton(gate_name, callback_data=f"opengate_{gate_id}")]) keyboard.append([InlineKeyboardButton(gate_name, callback_data=f"opengate_{gate_id}")])
@@ -45,7 +46,7 @@ async def open_gate_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, use
elif update.message: elif update.message:
await update.message.reply_text("Select a gate to open:", reply_markup=reply_markup) await update.message.reply_text("Select a gate to open:", reply_markup=reply_markup)
async def handle_gate_open_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, users: Users, gates: Gates): async def handle_gate_open_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, users: UsersService, gates: GatesService):
assert update.callback_query is not None assert update.callback_query is not None
query = update.callback_query query = update.callback_query
@@ -55,19 +56,14 @@ async def handle_gate_open_callback(update: Update, context: ContextTypes.DEFAUL
if data.startswith("opengate_"): if data.startswith("opengate_"):
gate_id = data[len("opengate_") :] gate_id = data[len("opengate_") :]
gate_name = gates.get_name(gate_id) gate_name = gates.get_name(gate_id)
user = users.get_user(user_id)
role = users.get_role(user_id) role = users.get_role(user_id)
if role in (Role.ADMIN, Role.MEMBER): if role in (Role.ADMIN, Role.MEMBER):
creds = users.get_credentials(user_id) effective_user = user
if not creds:
await query.answer("Please set your credentials with /setcredentials first", show_alert=True)
return
elif role == Role.GUEST and users.can_open_gate(user_id, gate_id): elif role == Role.GUEST and users.can_open_gate(user_id, gate_id):
grantor = users.get_grantor(user_id, gate_id) grantor = users.get_grantor(user, gate_id)
assert grantor is not None assert grantor is not None
creds = users.get_credentials(grantor) effective_user = grantor
if not grantor or not creds:
await query.answer("No valid grantor credentials available.", show_alert=True)
return
users.update_grant_last_used(user_id, gate_id) users.update_grant_last_used(user_id, gate_id)
try: try:
await context.bot.send_message(chat_id=grantor, text=f"Guest {user_id} opened {gate_name}") await context.bot.send_message(chat_id=grantor, text=f"Guest {user_id} opened {gate_name}")
@@ -78,7 +74,7 @@ async def handle_gate_open_callback(update: Update, context: ContextTypes.DEFAUL
await query.answer("Access denied.", show_alert=True) await query.answer("Access denied.", show_alert=True)
return return
if gates.open_gate(gate_id, creds): if gates.open_gate(gate_id, effective_user.uid):
await query.answer(f"Gate {gate_name} opened!", show_alert=True) await query.answer(f"Gate {gate_name} opened!", show_alert=True)
await query.edit_message_text(f"Gate {gate_name} opened!") await query.edit_message_text(f"Gate {gate_name} opened!")
else: else:

View File

@@ -1,11 +1,12 @@
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import Application, ContextTypes from telegram.ext import Application, ContextTypes
from models import Role from src.models import Role
from .gates import open_gate_menu from .gates import open_gate_menu
from .access import requestaccess from .access import requestaccess
from config import BotConfig from config import BotConfig
from services import UsersService from src.services import UsersService, GatesService
async def post_init(application: Application, bot_config: BotConfig) -> None: async def post_init(application: Application, bot_config: BotConfig) -> None:
await application.bot.set_my_name(bot_config.name) await application.bot.set_my_name(bot_config.name)
@@ -43,11 +44,11 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE, users_servic
reply_markup=reply_markup reply_markup=reply_markup
) )
async def handle_main_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, users: Users, gates: Gates): async def handle_main_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, users: UsersService, gates: GatesService):
query = update.callback_query query = update.callback_query
assert query is not None assert query is not None
data = query.data data = query.data
if data == "open_gate_menu": if data == "open_gate_menu":
await open_gate_menu(update, context, users=users, gates=gates) await open_gate_menu(update, context, users, gates)
elif data == "request_access": elif data == "request_access":
await requestaccess(update, context, users=users) await requestaccess(update, context, users=users)

View File

@@ -1,6 +1,6 @@
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from services import UsersService from src.services import UsersService
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE, users_service: UsersService): async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE, users_service: UsersService):
assert update.effective_user is not None assert update.effective_user is not None

View File

@@ -1,13 +1,13 @@
from .status import Status from .status import Status
class Gate: class Gate:
def __init__(self, id: str, name: str, status: Status = Status.ENABLED): def __init__(self, gid: str, name: str, status: Status = Status.ENABLED):
self.id = id self.gid = gid
self.name = name self.name = name
self.status = status if isinstance(status, Status) else Status(status) self.status = status
def to_dict(self): def to_dict(self):
return {"id": self.id, "name": self.name, "status": self.status.value} return {"id": self.gid, "name": self.name, "status": self.status.value}
@classmethod @classmethod
def from_dict(cls, data: dict): def from_dict(cls, data: dict):

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from models import Status from .status import Status
class Grant: class Grant:

View File

@@ -1,6 +1,4 @@
from enum import Enum from .grant import Grant
from . import Grant
from .role import Role from .role import Role
from .status import Status from .status import Status
from .credential import Credential from .credential import Credential
@@ -8,21 +6,21 @@ from .credential import Credential
class User: class User:
def __init__( def __init__(
self, self,
id: str, uid: str,
role: Role = Role.GUEST, role: Role = Role.GUEST,
credentials: Credential | None = None, credentials: Credential | None = None,
grants: dict[str, Grant] | None = None, grants: dict[str, Grant] | None = None,
status: Status = Status.ENABLED status: Status = Status.ENABLED
): ):
self.id = id self.uid = uid
self.role = role if isinstance(role, Role) else Role(role) self.role = role
self.credentials = credentials or Credential("", "") self.credentials = credentials or Credential("", "")
self.grants = grants or {} self.grants = grants or {}
self.status = status if isinstance(status, Status) else Status(status) self.status = status
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.uid,
"role": self.role.value, "role": self.role.value,
"credentials": self.credentials.to_dict(), "credentials": self.credentials.to_dict(),
"grants": {gate: grant.to_dict() for gate, grant in self.grants.items()}, "grants": {gate: grant.to_dict() for gate, grant in self.grants.items()},
@@ -30,11 +28,11 @@ class User:
} }
@classmethod @classmethod
def from_dict(cls, id: str, data: dict): def from_dict(cls, gid: str, data: dict):
credentials = Credential.from_dict(data.get("credentials", {})) credentials = Credential.from_dict(data.get("credentials", {}))
grants = {gate: Grant.from_dict(grant) for gate, grant in data.get("grants", {}).items()} grants = {gate: Grant.from_dict(grant) for gate, grant in data.get("grants", {}).items()}
return cls( return cls(
id=id, uid=gid,
role=Role(data.get("role", Role.GUEST)), role=Role(data.get("role", Role.GUEST)),
credentials=credentials, credentials=credentials,
grants=grants, grants=grants,

View File

@@ -1,5 +1,5 @@
import json import json
from models import Status, Gate from src.models import Status, Gate
class GatesRepository: class GatesRepository:
def __init__(self, json_path: str = "./data/gates.json"): def __init__(self, json_path: str = "./data/gates.json"):

View File

@@ -1,5 +1,5 @@
import json import json
from models import User from src.models import User
class UsersRepository: class UsersRepository:
def __init__(self, json_path: str = "./data/users.json"): def __init__(self, json_path: str = "./data/users.json"):
@@ -23,7 +23,7 @@ class UsersRepository:
json.dump({uid: user.to_dict() for uid, user in self._users.items()}, f, indent=2) json.dump({uid: user.to_dict() for uid, user in self._users.items()}, f, indent=2)
def delete(self, user: User) -> None: def delete(self, user: User) -> None:
del self._users[user.id] del self._users[user.uid]
self._dump_users() self._dump_users()
def get_by_uid(self, uid: str) -> User | None: def get_by_uid(self, uid: str) -> User | None:

View File

@@ -1,7 +1,7 @@
from fake_useragent import UserAgent from fake_useragent import UserAgent
from requests import Session from requests import Session
from models import Credential from src.models import Credential
class AVConnectService: class AVConnectService:

View File

@@ -1,6 +1,7 @@
from models import Credential, Status from src.models import Gate, Status
from repository import GatesRepository from src.repository import GatesRepository
from services import AVConnectService, UsersService from .avconnect_service import AVConnectService
from .users_service import UsersService
class GatesService: class GatesService:
@@ -15,8 +16,11 @@ class GatesService:
if not gate or gate.status == Status.DISABLED: if not gate or gate.status == Status.DISABLED:
return False return False
credentials = self._user_service.get_credentials(uid, gate_key) credentials = self._user_service.get_credentials(uid, gate_key)
return self._avconnect_service.open_gate_by_id(gate.id, credentials) return self._avconnect_service.open_gate_by_id(gate.gid, credentials)
def get_name(self, gate_key: str) -> str | None: def get_name(self, gate_key: str) -> str | None:
gate = self._gate_repository.get_by_key(gate_key) gate = self._gate_repository.get_by_key(gate_key)
return gate.name if gate else None return gate.name if gate else None
def get_all_enabled(self) -> list[Gate]:
return self._gate_repository.get_all_enabled()

View File

@@ -1,7 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from models import Status, Role, Credential, Grant, User from src.models import Status, Role, Credential, Grant, User
from repository import UsersRepository from src.repository import UsersRepository
class UsersService: class UsersService:
@@ -81,7 +81,7 @@ class UsersService:
return self._users_repository.get_by_uid(grant.grantor) if grant else None return self._users_repository.get_by_uid(grant.grantor) if grant else None
def get_admins(self) -> list[str]: def get_admins(self) -> list[str]:
return [user.id for user in self._users_repository.get_all() if user.role == Role.ADMIN] return [user.uid for user in self._users_repository.get_all() if user.role == Role.ADMIN]
def grant_access(self, uid: str, gate: str, expires_at: datetime, grantor_id: str) -> bool: def grant_access(self, uid: str, gate: str, expires_at: datetime, grantor_id: str) -> bool:
user = self._users_repository.get_by_uid(uid) user = self._users_repository.get_by_uid(uid)

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from models import Gate, Status from src.models import Gate, Status
from repository import GatesRepository from src.repository import GatesRepository
class TestGatesRepository(unittest.TestCase): class TestGatesRepository(unittest.TestCase):
@@ -15,7 +15,7 @@ class TestGatesRepository(unittest.TestCase):
result = self.repo.get_by_key(gate_key) result = self.repo.get_by_key(gate_key)
self.assertEqual(expected_gate.id, result.id) self.assertEqual(expected_gate.gid, result.gid)
self.assertEqual(expected_gate.name, result.name) self.assertEqual(expected_gate.name, result.name)
self.assertEqual(expected_gate.status, result.status) self.assertEqual(expected_gate.status, result.status)
@@ -33,7 +33,7 @@ class TestGatesRepository(unittest.TestCase):
self.assertEqual(len(enabled_gate_ids), len(result)) self.assertEqual(len(enabled_gate_ids), len(result))
self.assertEqual( self.assertEqual(
sorted(enabled_gate_ids), sorted([gate.id for gate in result]) sorted(enabled_gate_ids), sorted([gate.gid for gate in result])
) )
for gate in result: for gate in result:

View File

@@ -2,21 +2,24 @@
import unittest import unittest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from models import Credential, Gate, Status from src.models import Credential, Gate, Status
from repository import GatesRepository from src.repository import GatesRepository, UsersRepository
from services import AVConnectService, GatesService from src.services import AVConnectService, GatesService, UsersService
class TestGatesService(unittest.TestCase): class TestGatesService(unittest.TestCase):
def setUp(self): def setUp(self):
self.mock_users_json_path = "../resources/mock_users.json"
self.gates_repo = MagicMock(spec=GatesRepository) self.gates_repo = MagicMock(spec=GatesRepository)
self.users_repo = UsersRepository(self.mock_users_json_path)
self.avconnect_service = MagicMock(spec=AVConnectService) self.avconnect_service = MagicMock(spec=AVConnectService)
self.service = GatesService(self.gates_repo, self.avconnect_service) self.users_service = UsersService(self.users_repo)
self.service = GatesService(self.gates_repo, self.avconnect_service, self.users_service)
def test_open_gate_fails_when_gate_is_disabled(self): def test_open_gate_fails_when_gate_is_disabled(self):
gate_key = "gate1" gate_key = "gate1"
credential = Credential(username="user", password="pass") credential = Credential(username="user", password="pass")
gate = Gate(id="1", name="Test Gate", status=Status.DISABLED) gate = Gate(gid="1", name="Test Gate", status=Status.DISABLED)
self.gates_repo.get_by_key.return_value = gate self.gates_repo.get_by_key.return_value = gate
result = self.service.open_gate(gate_key, credential) result = self.service.open_gate(gate_key, credential)
@@ -28,7 +31,7 @@ class TestGatesService(unittest.TestCase):
def test_open_gate_succeeds_with_valid_enabled_gate(self): def test_open_gate_succeeds_with_valid_enabled_gate(self):
gate_key = "gate2" gate_key = "gate2"
credential = Credential(username="user", password="pass") credential = Credential(username="user", password="pass")
gate = Gate(id="2", name="Enabled Gate", status=Status.ENABLED) gate = Gate(gid="2", name="Enabled Gate", status=Status.ENABLED)
self.gates_repo.get_by_key.return_value = gate self.gates_repo.get_by_key.return_value = gate
self.avconnect_service.open_gate_by_id.return_value = True self.avconnect_service.open_gate_by_id.return_value = True
@@ -36,12 +39,12 @@ class TestGatesService(unittest.TestCase):
self.assertTrue(result) self.assertTrue(result)
self.gates_repo.get_by_key.assert_called_once_with(gate_key) self.gates_repo.get_by_key.assert_called_once_with(gate_key)
self.avconnect_service.open_gate_by_id.assert_called_once_with(gate.id, credential) self.avconnect_service.open_gate_by_id.assert_called_once_with(gate.gid, credential)
def test_open_gate_handles_exception_gracefully(self): def test_open_gate_handles_exception_gracefully(self):
gate_key = "gate3" gate_key = "gate3"
credential = Credential(username="user", password="pass") credential = Credential(username="user", password="pass")
gate = Gate(id="3", name="Test Gate", status=Status.ENABLED) gate = Gate(gid="3", name="Test Gate", status=Status.ENABLED)
self.gates_repo.get_by_key.return_value = gate self.gates_repo.get_by_key.return_value = gate
self.avconnect_service.open_gate_by_id.side_effect = Exception("Test Exception") self.avconnect_service.open_gate_by_id.side_effect = Exception("Test Exception")
@@ -49,7 +52,7 @@ class TestGatesService(unittest.TestCase):
self.assertFalse(result) self.assertFalse(result)
self.gates_repo.get_by_key.assert_called_once_with(gate_key) self.gates_repo.get_by_key.assert_called_once_with(gate_key)
self.avconnect_service.open_gate_by_id.assert_called_once_with(gate.id, credential) self.avconnect_service.open_gate_by_id.assert_called_once_with(gate.gid, credential)
def test_open_gate_returns_false_when_gate_does_not_exist(self): def test_open_gate_returns_false_when_gate_does_not_exist(self):
gate_key = "nonexistent_gate" gate_key = "nonexistent_gate"