diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..01df53a5bdc4e9913c604bc96a08baabf0171f2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.idea/ +venv/ +myenv/ + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..ac54e3095f0ee3e8dab050054d61cc09a9724018 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright (c) 2024 Fachschaft I/1 der RWTH Aachen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/run_tests.py b/src/run_tests.py new file mode 100644 index 0000000000000000000000000000000000000000..2e8c81463e7d4ab30a00a28b84c4d45b39ee7638 --- /dev/null +++ b/src/run_tests.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + +if __name__ == '__main__': + import app + import unittest + app.app.testing = True + # This is always run from src/ + suite = unittest.defaultTestLoader.discover("../tests/", pattern="*", top_level_dir="../tests") + if not unittest.TextTestRunner(verbosity=2, failfast=True).run(suite).wasSuccessful(): + exit(-1) diff --git a/src/videoag_common/__init__.py b/src/videoag_common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/videoag_common/api_object/__init__.py b/src/videoag_common/api_object/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40d03548f82cb56d5b486c441bbca10c3cdf463c --- /dev/null +++ b/src/videoag_common/api_object/__init__.py @@ -0,0 +1,9 @@ +from .fields import * +from .object import ( + ApiObject, + DeletableApiObject, + VisibilityApiObject, + api_mapped, + api_include_in_data, +) +from .object_class import ApiObjectClass diff --git a/src/videoag_common/api_object/fields/__init__.py b/src/videoag_common/api_object/fields/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23121253efb2da24382078e9dd554ee2fd30fcd2 --- /dev/null +++ b/src/videoag_common/api_object/fields/__init__.py @@ -0,0 +1,21 @@ +from .field import ( + ApiField, + ApiConfigField, + ApiDataField, + FieldContext +) +from .basic_fields import ( + ApiStringField, + ApiIntegerField, + ApiBooleanField, + ApiDatetimeField, + ApiEnumField +) +from .relationship_fields import ( + ApiMany2OneRelationshipField, + ApiAny2ManyRelationshipField +) +from .special_fields import ( + ApiDataMethodField, + ApiJsonField +) diff --git a/src/videoag_common/api_object/fields/basic_fields.py b/src/videoag_common/api_object/fields/basic_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..ccbda0e6761ece3900264d69ca542be9d9c5b79a --- /dev/null +++ b/src/videoag_common/api_object/fields/basic_fields.py @@ -0,0 +1,186 @@ +import enum +import re +from datetime import datetime +from typing import Generic, TypeVar + +from videoag_common.miscellaneous import * +from videoag_common.database import * +from .field import ApiSimpleColumnField, FieldContext + +_O = TypeVar("_O", bound="ApiObject") + + +class ApiStringField(ApiSimpleColumnField[_O], Generic[_O]): + + def __init__(self, + max_length: int or None = None, + min_length: int = 0, + unique: bool = False, + regex: str or None = None, + **kwargs): + super().__init__(**kwargs, type_id="string") + self.min_length = min_length + self.max_length = max_length + self._unique = unique + self._pattern = None if regex is None else re.compile(regex) + + def post_init(self, context: FieldContext): + super().post_init(context) + + if not isinstance(self._column.type, sql.types.String): + raise TypeError(f"SQL type for string field must be string, but is '{self._column.type}'") + + if self._column.type.length is not None: + if self.max_length is not None and self.max_length > self._column.type.length: + raise ValueError("Maximum length of field is higher than maximum length of sql type") + if self.max_length is None: + self.max_length = self._column.type.length + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if self.max_length is None and self.include_in_config: + raise ValueError("A maximum length must be specified for a string field which is included in the config. " + "Either with sql length or on api field") + + def _add_type_restrictions_to(self, config_entry: dict): + super()._add_type_restrictions_to(config_entry) + config_entry["min_length"] = self.min_length + config_entry["max_length"] = self.max_length + if self._pattern is not None: + config_entry["pattern"] = self._pattern.pattern + + def _db_value_to_json(self, db_value) -> JsonTypes: + return db_value + + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + value_db = json_value.as_string(min_length=self.min_length, max_length=self.max_length) + if self._pattern is not None and not self._pattern.fullmatch(value_db): + json_value.raise_error(f"Value must match pattern: {self._pattern.pattern}") + + # TODO unique + return value_db + + +class ApiIntegerField(ApiSimpleColumnField[_O], Generic[_O]): + + def __init__(self, + min_value: int = MIN_VALUE_SINT32, + max_value: int = MAX_VALUE_SINT32, + **kwargs): + super().__init__(**kwargs, type_id="int") + self.min_value = min_value + self.max_value = max_value + + if self.min_value > self.max_value: + raise ValueError("Min value is greater than max value") + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if not isinstance(self._column.type, sql.types.Integer): + raise TypeError(f"SQL type for integer field must be integer, but is '{self._column.type}'") + + def _add_type_restrictions_to(self, config_entry: dict): + super()._add_type_restrictions_to(config_entry) + config_entry["min_value"] = self.min_value + config_entry["max_value"] = self.max_value + + def _db_value_to_json(self, db_value) -> JsonTypes: + return db_value + + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + return json_value.as_int(min_value=self.min_value, max_value=self.max_value) + + +class ApiBooleanField(ApiSimpleColumnField[_O], Generic[_O]): + + def __init__(self, **kwargs): + super().__init__(**kwargs, type_id="boolean") + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if self.may_be_none: + raise ValueError("Boolean field may not be nullable") + + if not isinstance(self._column.type, sql.types.Boolean): + raise TypeError(f"SQL type for boolean field must be boolean, but is '{self._column.type}'") + + def _db_value_to_json(self, db_value) -> JsonTypes: + return db_value + + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + return json_value.as_bool() + + +class ApiDatetimeField(ApiSimpleColumnField[_O], Generic[_O]): + + def __init__(self, may_be_none: bool or None = None, **kwargs): + super().__init__(**kwargs, type_id="datetime") + self._may_be_none = may_be_none + + def post_init(self, context: FieldContext): + super().post_init(context) + + if self._may_be_none is None: + self._may_be_none = self._column.nullable + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if not isinstance(self._column.type, sql.types.DateTime): + raise TypeError(f"SQL type for datetime field must be datetime, but is '{self._column.type}'") + + @property + def may_be_none(self) -> bool: + return self._may_be_none + + def _db_value_to_json(self, db_value) -> JsonTypes: + return db_value.strftime(DEFAULT_DATETIME_FORMAT) + + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + time_string = json_value.as_string(100) + try: + return datetime.strptime(time_string, DEFAULT_DATETIME_FORMAT) + except ValueError: + json_value.raise_error("Invalid datetime format") + + +class ApiEnumField(ApiSimpleColumnField[_O], Generic[_O]): + + def __init__(self, **kwargs): + super().__init__(**kwargs, type_id="enum") + self.enum_class = None + self.str_enums: list[str] = [] + self.enums_by_id: dict[str, enum.Enum] = {} + + def post_init(self, context: FieldContext): + super().post_init(context) + + if not isinstance(self._column.type, sql.types.Enum): + raise TypeError(f"SQL type for enum field must be enum, but is '{self._column.type}'") + + self.enum_class = self._column.type.enum_class + + for enum in self.enum_class: + if not isinstance(enum.value, str): + raise ValueError(f"Enum '{self.enum_class}' for API field has non-string constant: '{enum.value}'") + if enum.value in self.enums_by_id: + raise ValueError(f"Enum '{self.enum_class}' for API field has duplicate constant: '{enum.value}'") + self.enums_by_id[enum.value] = enum + + self.str_enums = list(self.enums_by_id.keys()) + + def _add_type_restrictions_to(self, config_entry: dict): + super()._add_type_restrictions_to(config_entry) + config_entry["enums"] = self.str_enums + + def _db_value_to_json(self, db_value) -> JsonTypes: + return db_value.value + + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + enum_id = json_value.as_string(1000) + if enum_id not in self.enums_by_id: + json_value.raise_error("Unknown enum value") + return self.enums_by_id[enum_id] diff --git a/src/videoag_common/api_object/fields/field.py b/src/videoag_common/api_object/fields/field.py new file mode 100644 index 0000000000000000000000000000000000000000..b0f05810df74d866e7707b3f219e64b93411112b --- /dev/null +++ b/src/videoag_common/api_object/fields/field.py @@ -0,0 +1,293 @@ +from abc import ABC, abstractmethod +from copy import copy +from dataclasses import dataclass +from typing import Generic, TypeVar, Callable, Any, Self + +from videoag_common.miscellaneous import * +from videoag_common.database import * + +_O = TypeVar("_O", bound="ApiObject") + + +@dataclass +class FieldContext: + all_classes: dict[str, "ApiObjectClass"] + api_class: "ApiObjectClass" + own_member: Any + variant_id: str or None + + +class ApiField(Generic[_O], ABC): + + def __init__(self, **kwargs): + if len(kwargs) > 0: + raise ValueError(f"Got unexpected argument(s): {kwargs.keys()}") + self.variant_id = None + + self._api_class = None + self.member_name = None + + def set_member_name(self, member_name: str): + self.member_name = member_name + + def pre_post_init_copy(self) -> Self: + return copy(self) + + def post_init(self, context: FieldContext): + self._api_class = context.api_class + self.variant_id = context.variant_id + + def post_init_check(self, context: FieldContext): + pass + + def get_api_class(self) -> "ApiObjectClass": + return self._api_class + + def __repr__(self): + paras = [] + if isinstance(self, ApiConfigField) and self.include_in_config: + paras.append(f"config_id={self.config_id}") + + if isinstance(self, ApiDataField) and self.include_in_data: + paras.append(f"data_id={self.data_id}") + + return f"{type(self).__name__}[{', '.join(paras)}]" + + +class ApiConfigField(ApiField[_O], Generic[_O]): + + def __init__(self, + include_in_config: bool = False, + config_id: str or None = None, + config_only_at_creation: bool = False, + config_directly_modifiable: bool = False, + config_default_value: JsonTypes or tuple[None] or None = None, + **kwargs): + super().__init__(**kwargs) + self.include_in_config = include_in_config + self.config_id = config_id + self.config_only_at_creation = config_only_at_creation + self.config_directly_modifiable = config_directly_modifiable + self.config_default_value = config_default_value + + self.__description = None + + def post_init(self, context: FieldContext): + super().post_init(context) + + if self.config_id is None: + self.config_id = self.member_name + + if self.config_id is None: + raise ValueError("No config id for field") + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if self.config_directly_modifiable and not ( + isinstance(self, ApiDataField) and self.include_in_data + ): + raise ValueError("config_directly_modifiable can only be set when include_in_data is set") + + def config_get_description(self) -> JsonTypes or None: + if not self.include_in_config: + raise Exception("Config not enabled for field") + if self.__description is not None: + return self.__description + + self.__description = { + "id": self.config_id, + "type": self.config_get_type_id() + } + if self.config_default_value is not None: + self.__description["default_value"] = ( + self.config_default_value if self.config_default_value != (None,) else None + ) + self._add_type_restrictions_to(self.__description) + return self.__description + + def config_get_value_with_description(self, object_db: _O) -> JsonTypes or None: + if not self.include_in_config: + raise Exception("Config not enabled for field") + return { + "description": self.config_get_description(), + "value": self.config_get_value(object_db) + } + + def _add_type_restrictions_to(self, config_entry: dict): + """ + Added parameters need to be added to the api documentation (In routes/object_modifications.py) + """ + pass + + @abstractmethod + def config_get_type_id(self) -> str: + pass + + @abstractmethod + def config_get_value(self, object_db: _O) -> JsonTypes: + pass # pragma: no cover + + @abstractmethod + def config_set_value(self, + session: SessionDb, + object_db: _O, + new_client_value: CJsonValue): + pass # pragma: no cover + + @abstractmethod + def config_get_default(self) -> JsonTypes: + """ + None indicates not default. (None, ) indicates a None default + """ + pass # pragma: no cover + + +class ApiDataField(ApiField[_O], Generic[_O]): + + def __init__(self, + include_in_data: bool = False, + data_id: str or None = None, + data_only_mod: bool = False, + data_if: Callable[[_O, dict], bool] or None = None, + data_notes: str = "", + **kwargs): + super().__init__(**kwargs) + self.include_in_data = include_in_data + self.data_id = data_id + self.data_only_mod = data_only_mod + self._data_if = data_if + self.data_notes = data_notes + + def post_init(self, context: FieldContext): + super().post_init(context) + + if self.data_id is None: + self.data_id = self.member_name + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if self.data_id is None: + raise ValueError("No data id for field") + + def data_is_optional(self) -> bool: + return self._data_if is not None + + def add_to_data(self, object_db: _O, json: dict, args: Any): + if not self.include_in_data: + return + if self._data_if and not self._data_if(object_db, args): + return + if self.data_only_mod and not args.is_mod: + return + json[self.data_id] = self.data_get_value(object_db, args) + + @abstractmethod + def data_get_type_id(self) -> str: + pass + + @abstractmethod + def data_get_value(self, object_db: _O, args) -> JsonTypes: + pass + + +class ApiAbstractColumnField(ApiConfigField[_O], ApiDataField[_O], Generic[_O]): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._column: sql.Column or None = None + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + def config_get_value(self, object_db: _O) -> JsonTypes: + db_value = getattr(object_db, self.member_name) + if self.may_be_none and db_value is None: + return None + return self._config_db_value_to_json(db_value) + + def config_set_value(self, session: SessionDb, object_db: _O, new_client_value: CJsonValue): + if self.may_be_none and new_client_value.is_null(): + new_value_db = None + else: + new_value_db = self._config_json_value_to_db(session, object_db, new_client_value) + setattr(object_db, self.member_name, new_value_db) + + def data_get_value(self, object_db: _O, args) -> JsonTypes: + db_value = getattr(object_db, self.member_name) + if self.may_be_none and db_value is None: + return None + return self._data_db_value_to_json(db_value, args) + + def config_get_default(self) -> JsonTypes or (None,): + if self.config_default_value is not None: + return self.config_default_value + if self.may_be_none: + return (None, ) + return None + + @property + def may_be_none(self) -> bool: + return False + + @abstractmethod + def _data_db_value_to_json(self, db_value, args) -> JsonTypes: + pass + + @abstractmethod + def _config_db_value_to_json(self, db_value) -> JsonTypes: + pass + + @abstractmethod + def _config_json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + pass + + +class ApiSimpleColumnField(ApiAbstractColumnField[_O], Generic[_O]): + + def __init__(self, type_id: str, **kwargs): + super().__init__(**kwargs) + self.type_id = type_id + + def data_get_type_id(self) -> str: + return self.type_id + + def config_get_type_id(self) -> str: + return self.type_id + + def post_init(self, context: FieldContext): + super().post_init(context) + + if not isinstance(context.own_member, orm.InstrumentedAttribute): + raise TypeError(f"Member for column fields must be of type '{orm.InstrumentedAttribute}'") + if not isinstance(context.own_member.prop, orm.ColumnProperty): + raise TypeError(f"Property of member for column fields must be of type '{orm.ColumnProperty}'") + + if len(context.own_member.prop.columns) != 1: + raise ValueError("Column property doesn't have exactly one column. This is not supported") + + column = context.own_member.prop.columns[0] + if column.foreign_keys: + print(f"Warning: Column '{column}' mapped in the API has foreign keys. For proper checks and " + f"errors, add an API mapping to an orm relationship instead.") + + self._column = column + + def _config_json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + return self._json_value_to_db(session, object_db, json_value) + + def _config_db_value_to_json(self, db_value) -> JsonTypes: + return self._db_value_to_json(db_value) + + def _data_db_value_to_json(self, db_value, args) -> JsonTypes: + return self._db_value_to_json(db_value) + + @abstractmethod + def _db_value_to_json(self, db_value) -> JsonTypes: + pass + + @abstractmethod + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + pass diff --git a/src/videoag_common/api_object/fields/relationship_fields.py b/src/videoag_common/api_object/fields/relationship_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..5b16192d574a4d6e17e22e764c80f63110ac5c0b --- /dev/null +++ b/src/videoag_common/api_object/fields/relationship_fields.py @@ -0,0 +1,164 @@ +from abc import ABC +from typing import Generic, TypeVar + +from videoag_common.database import * +from videoag_common.miscellaneous import * + +from ..util import get_relationship_foreign_class +from .field import ApiAbstractColumnField, FieldContext + +_O = TypeVar("_O", bound="ApiObject") + + +class ApiAbstractRelationshipField(ApiAbstractColumnField[_O], Generic[_O], ABC): + + def __init__(self, + data_foreign_in_context: bool = False, + **kwargs): + super().__init__(**kwargs) + self.data_foreign_in_context = data_foreign_in_context + + self._foreign_class = None + self._relationship: orm.Relationship or None = None + + def post_init(self, context: FieldContext): + super().post_init(context) + + if not isinstance(context.own_member, orm.InstrumentedAttribute): + raise TypeError(f"Member for relationship fields must be of type '{orm.InstrumentedAttribute}'") + if not isinstance(context.own_member.prop, orm.Relationship): + raise TypeError(f"Property of member for relationship fields must be of type '{orm.Relationship}'") + + self._relationship = context.own_member.prop + self._foreign_class = get_relationship_foreign_class( + context.all_classes, + self._relationship + ) + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if self._relationship.viewonly and self.include_in_config: + raise ValueError(f"include_in_config is True and viewonly is True for relationship '{self._relationship}'") + + def _serialize_relationship(self, obj, args): + if not self.data_foreign_in_context: + return self._foreign_class.serialize_object_args(obj, args) + id = obj.id + context_name = f"{self._foreign_class.id}_context" + if not hasattr(args, context_name): + raise Exception(f"Missing serialization argument '{context_name}'") + context = getattr(args, context_name) + if not isinstance(context, dict): + raise Exception(f"serialization argument '{context_name}' must be a dict") + if str(id) not in context: + context[str(id)] = None # Indicate that object is being serialized. Prevent loops + context[str(id)] = self._foreign_class.serialize_object_args(obj, args) + return id + + +class ApiMany2OneRelationshipField(ApiAbstractRelationshipField[_O], Generic[_O]): + + def __init__(self, may_be_none: bool or None = None, **kwargs): + super().__init__(**kwargs) + self._may_be_none = may_be_none + + def post_init(self, context: FieldContext): + if self.data_foreign_in_context and self.data_id is None and self.member_name is not None: + self.data_id = f"{self.member_name}_id" + + super().post_init(context) + + if self._may_be_none is None: + self._may_be_none = list(self._relationship.local_columns)[0].nullable + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if len(self._relationship.local_columns) != 1: + raise ValueError(f"Relationship '{self._relationship}' has more than one local column: " + f"'{self._relationship.local_columns}'") + + if self._relationship.collection_class is not None: + raise ValueError(f"Relationship '{self._relationship}' may reference multiple objects. Use a many2many " + f"relationship instead") + + @property + def may_be_none(self) -> bool: + return self._may_be_none + + def config_get_type_id(self) -> str: + return self._foreign_class.id + + def data_get_type_id(self) -> str: + return "int" if self.data_foreign_in_context else self._foreign_class.id + + def _data_db_value_to_json(self, db_value, args) -> JsonTypes: + return self._serialize_relationship(db_value, args) + + def _config_db_value_to_json(self, db_value) -> JsonTypes: + return db_value.id + + def _config_json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + id = json_value.as_sint32() + obj = session.scalar( + self._foreign_class.orm_class.basic_select() + .where(self._foreign_class.sql_id_column == id) + ) + if obj is None: + json_value.raise_error("Unknown object") + return obj + + +class ApiAny2ManyRelationshipField(ApiAbstractRelationshipField[_O], Generic[_O]): + """ + One class for many2many and one2many relationships. These are the same because in both cases we have a list of objects + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def post_init(self, context: FieldContext): + if self.data_foreign_in_context and self.data_id is None and self.member_name is not None: + self.data_id = f"{self.member_name}_ids" + + super().post_init(context) + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if self._relationship.collection_class is None: + raise ValueError(f"Relationship '{self._relationship}' can not reference multiple objects. Use a many2one " + f"relationship instead") + + if self.may_be_none: + raise ValueError("may_be_none may not be set for many2many relationship") + + def config_get_type_id(self) -> str: + return self._foreign_class.id + "[]" + + def data_get_type_id(self) -> str: + return ("int" if self.data_foreign_in_context else self._foreign_class.id) + "[]" + + def _data_db_value_to_json(self, db_value, args) -> JsonTypes: + return list(map( + lambda obj: self._serialize_relationship(obj, args), + db_value + )) + + def _config_db_value_to_json(self, db_value) -> JsonTypes: + return [obj.id for obj in db_value] + + def _config_json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + ids = [] + for val in json_value.as_array(): + ids.append(val.as_sint32()) + + objects = session.scalars( + self._foreign_class.orm_class.basic_select() + .where(self._foreign_class.sql_id_column.in_(ids)) + ).all() + if len(objects) != len(ids): + missing_ids = set(ids) - set(map(lambda obj: obj.id, objects)) + json_value.raise_error(f"Unknown objects with ids: '{missing_ids}'") + return objects diff --git a/src/videoag_common/api_object/fields/special_fields.py b/src/videoag_common/api_object/fields/special_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..3853695ee9cbb10c664b63d1005de3a5278b4c91 --- /dev/null +++ b/src/videoag_common/api_object/fields/special_fields.py @@ -0,0 +1,64 @@ +from inspect import signature +from typing import TypeVar, Generic, Callable, Any + +from videoag_common.miscellaneous import * +from videoag_common.database import * +from .field import ApiDataField, FieldContext, ApiSimpleColumnField + +_O = TypeVar("_O", bound="ApiObject") + + +class ApiDataMethodField(ApiDataField[_O], Generic[_O]): + + # TODO get type id from function return type + def __init__(self, type_id: str, **kwargs): + super().__init__(**kwargs) + self._data_func: Callable[[_O, Any], JsonTypes] or None = None + self._serializer = None + self._type_id: str = type_id + + def data_get_type_id(self) -> str: + return self._type_id + + def post_init(self, context: FieldContext): + super().post_init(context) + if not callable(context.own_member): + raise TypeError(f"Member for '{ApiDataMethodField}' must be a callable") + self._data_func = context.own_member + + from ..object import api_get_value_serializer_function + self._serializer = api_get_value_serializer_function(context.all_classes, self._type_id) + + def data_get_value(self, object_db: _O, args) -> JsonTypes: + kwargs = {} + for para_name, para in signature(self._data_func).parameters.items(): + if para_name == "self": + continue + if not hasattr(args, para_name): + raise ValueError(f"Missing parameter '{para_name}' for serialization of field {self.data_id} in {self.get_api_class().id}") + kwargs[para_name] = getattr(args, para_name) + val = self._data_func(object_db, **kwargs) + return self._serializer(val, args) + + +class ApiJsonField(ApiSimpleColumnField[_O], Generic[_O]): + + def __init__(self, **kwargs): + super().__init__(**kwargs, type_id="json") + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + if not isinstance(self._column.type, sql.types.JSON): + raise TypeError(f"SQL type for json field must be json, but is '{self._column.type}'") + + if self.include_in_config: + raise ValueError("ApiJsonField cannot have include_in_config") + + def _db_value_to_json(self, db_value) -> JsonTypes: + return db_value + + def _json_value_to_db(self, session: SessionDb, object_db: _O, json_value: CJsonValue): + raise AssertionError() + + diff --git a/src/videoag_common/api_object/object.py b/src/videoag_common/api_object/object.py new file mode 100644 index 0000000000000000000000000000000000000000..1a003b279690b56fb7bcfac993a39fe72216344b --- /dev/null +++ b/src/videoag_common/api_object/object.py @@ -0,0 +1,203 @@ +from abc import abstractmethod +from typing import TypeVar, Callable + +from videoag_common.miscellaneous import * +from videoag_common.database import * +from .fields import * + +_T = TypeVar("_T") +_O = TypeVar("_O") + + +_MappedT = TypeVar("_MappedT", bound=orm.Mapped) + + +def api_mapped( + mapped: _MappedT, + field: ApiField, +) -> _MappedT: + if isinstance(mapped, orm.MappedColumn): + setattr(mapped.column, "video_ag_api_field", field) + elif isinstance(mapped, orm.Relationship): + setattr(mapped, "video_ag_api_field", field) + else: + raise TypeError(f"Unsupported mapped object: '{type(mapped)}'. Only MappedColumn and Relationship are supported") + return mapped + + +def api_include_in_data( + **kwargs +): + field = ApiDataMethodField(include_in_data=True, **kwargs) + + def decorator(func): + setattr(func, "video_ag_api_field", field) + return func + + return decorator + + +API_VALUE_OBJECT_CLASSES_BY_ID: dict[str, type["ApiValueObject"]] = {} +_disable_value_object_registration = False + + +class ApiValueObject: + + def __init_subclass__(cls): + super().__init_subclass__() + if hasattr(cls, "__api_id__"): + if _disable_value_object_registration: + raise ValueError("Cannot register new ApiValueObject class. Make sure they are imported when initializing" + "the orm object classes") + + id = cls.__api_id__ + if id in API_VALUE_OBJECT_CLASSES_BY_ID: + raise ValueError(f"Duplicate value object id '{id}'") + API_VALUE_OBJECT_CLASSES_BY_ID[id] = cls + + @abstractmethod + def serialize(self) -> JsonTypes: + pass + + +class ApiObject: + id: Mapped[int] = api_mapped( + mapped_column(nullable=False, primary_key=True, autoincrement=True, sort_order=-1), + ApiIntegerField( + include_in_data=True + ) + ) + + @staticmethod + def __init_field_list(clazz): + + if hasattr(clazz, "__api_fields__"): + api_fields = list(clazz.__api_fields__) + delattr(clazz, "__api_fields__") # Prevent that this variable is inherited by other classes + else: + api_fields = [] + + setattr(clazz, "__all_class_api_fields__", api_fields) + for name, member in clazz.__dict__.items(): + if isinstance(member, orm.MappedColumn) and hasattr(member.column, "video_ag_api_field"): + field = member.column.video_ag_api_field + delattr(member.column, "video_ag_api_field") + elif hasattr(member, "video_ag_api_field"): + field = member.video_ag_api_field + delattr(member, "video_ag_api_field") + else: + continue + field.set_member_name(name) + api_fields.append(field) + + def __init_subclass__(cls, **kwargs): + # We get the field data here, before calling init_subclass of the orm because the orm changes all the + # attributes, and then we can't find our custom attributes + + # Also init list for own class if that hasn't happened yet + if not hasattr(ApiObject, "__all_class_api_fields__"): + ApiObject.__init_field_list(ApiObject) + + ApiObject.__init_field_list(cls) + super().__init_subclass__(**kwargs) + + @hybrid_method + def is_active(self): + """ Select conditions to get all objects which are still meaningful (e.g. everything which is not deleted)""" + if isinstance(self, type): + return sql.True_() + else: + return True + + @hybrid_method + def has_access(self, is_mod: bool, **kwargs): + """ Select conditions to get all objects with some access restrictions. If is_mod=True, this must be effectively + the same as is_active()""" + if len(kwargs) > 0: + raise Exception(f"Unknown kwargs: {kwargs}") + return self.is_active() + + @classmethod + def basic_select(cls): + return sql.select(cls).where(cls.is_active()) + + @classmethod + def select(cls, is_mod: bool, **kwargs): + return cls.basic_select().where(cls.has_access(is_mod, **kwargs)) + + def serialize(self, **kwargs): + return self.__api_class__.serialize(self, **kwargs) + + +ApiValueTypes = ApiObject or ApiValueObject or str or int or bool or None or list["ApiValueType"] + + +def api_get_value_serializer_function(all_classes: dict[str, "ApiObjectClass"], type_id: str) \ + -> Callable[[ApiValueTypes, ArgAttributeObject], JsonTypes]: + full_type_id = type_id + is_array = False + if type_id.endswith("[]"): + is_array = True + type_id = type_id[:len(type_id) - 2] + + _disable_value_object_registration = True + + if type_id in API_VALUE_OBJECT_CLASSES_BY_ID: + if type_id in all_classes: + raise ValueError(f"Duplicate id '{type}' in API classes and value classes") + value_class = API_VALUE_OBJECT_CLASSES_BY_ID[type_id] + + def serializer(val: ApiValueTypes, args): + if val is None: + return val + if not isinstance(val, value_class): + raise TypeError(f"Got object of type '{type(val)}' but it was previously declared to be a '{value_class}'") + return val.serialize() + elif type_id in all_classes: + object_class = all_classes[type_id] + + def serializer(val: ApiValueTypes, args): + if val is None: + return val + if not isinstance(val, object_class.orm_class): + raise TypeError(f"Got object of type '{type(val)}' but it was previously declared to be a '{object_class.orm_class}'") + return object_class.serialize_object_args(val, args) + elif type_id in ("string", "integer", "boolean"): + def serializer(val: ApiValueTypes, args): + return val + else: + raise ValueError(f"Unknown type '{full_type_id}' (For ApiValueObject make sure that the __api_id__ attribute is set)") + + if not is_array: + return serializer + + def serializer_list(val_list: list[ApiValueTypes], args): + if val_list is None: + return val_list + return list(map(lambda v: serializer(v, args), val_list)) + return serializer_list + + +class DeletableApiObject(ApiObject): + deleted: Mapped[bool] = mapped_column(nullable=False, default=False) + + @hybrid_method + def is_active(self): + return super().is_active() & ~self.deleted + + +class VisibilityApiObject(ApiObject): + visible: Mapped[bool] = api_mapped( + mapped_column(sql.types.Boolean(), nullable=False, default=False), + ApiBooleanField( + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, data_only_mod=True + ) + ) + + @hybrid_method + def has_access(self, is_mod: bool, visibility_check: bool = True, **kwargs): + cond = super().has_access(is_mod, **kwargs) + if not is_mod and visibility_check: + cond &= self.visible + return cond diff --git a/src/videoag_common/api_object/object_class.py b/src/videoag_common/api_object/object_class.py new file mode 100644 index 0000000000000000000000000000000000000000..45badf98dde7df168e8eb0a439887b4f0e75195b --- /dev/null +++ b/src/videoag_common/api_object/object_class.py @@ -0,0 +1,541 @@ +from enum import Enum + +from videoag_common.database import * +from videoag_common.miscellaneous import * +from .fields import * +from .object import ApiObject, DeletableApiObject +from .util import get_relationship_foreign_class + + +def _get_variant_id(clazz: type[ApiObject], optional: bool = False) -> str or None: + identity = None + if hasattr(clazz, "__mapper_args__"): + identity = clazz.__mapper_args__.get("polymorphic_identity") + if identity is None: + if optional: + return None + raise Exception(f"Missing 'polymorphic_identity' in '__mapper_args__' for class '{clazz.__name__}'") + if isinstance(identity, Enum): + identity = identity.value + + if not isinstance(identity, str): + raise Exception(f"'polymorphic_identity' in '__mapper_args__' for class '{clazz.__name__}' is not a string " + f"or an enum with a string value") + + return identity + + +class ApiObjectClass: + + def __init__(self, + parent_relationship_config_ids: list[str] or None = None, + enable_config: bool or None = None, + config_allow_creation: bool = True, + enable_data: bool or None = None, + ): + self._parent_relationship_config_ids = parent_relationship_config_ids + self.enable_config = enable_config + self.config_allow_creation = config_allow_creation + self.enable_data = enable_data + + self.orm_class = None + self.id = None + self.sql_table = None + self.sql_id_column = None + + self._fields_by_variant_by_config_id: dict[str or None, dict[str, ApiConfigField]] = { + None: {} + } + self._fields_by_variant_by_data_id: dict[str or None, dict[str, ApiDataField]] = { + None: {} + } + + self._parent_relationship_id_by_class_id = {} + + self._variant_field: ApiEnumField or None = None + + def set_class(self, orm_class: type[ApiObject]): + self.orm_class = orm_class + self.id = alphanum_camel_case_to_snake_case(orm_class.__name__) + + self.sql_table = Base.metadata.tables[self.id] + self.sql_id_column = self.sql_table.columns["id"] + + def _post_init(self, all_classes: dict[str, "ApiObjectClass"]): + self._post_init_fields(all_classes) + self._post_init_variants() + self._post_init_variant_fields(all_classes) + + if self.enable_config is None: + self.enable_config = any(map( + lambda fields_by_id: len(fields_by_id) > 0, + self._fields_by_variant_by_config_id.values() + )) + + if self.enable_data is None: + self.enable_data = any(map( + lambda fields_by_id: len(fields_by_id) > 0, + self._fields_by_variant_by_data_id.values() + )) + + self._post_init_parents(all_classes) + self._post_init_creation_config() + + def _post_init_fields(self, all_classes: dict[str, "ApiObjectClass"]): + for clazz in set(recursive_flat_map_single(lambda c: c.__bases__, self.orm_class)): + if not hasattr(clazz, "__all_class_api_fields__"): + continue + # noinspection PyUnresolvedReferences + for field in clazz.__all_class_api_fields__: + assert isinstance(field, ApiField) + try: + self._add_field(all_classes, field, None, None) + except Exception as e: + raise Exception( + f"While initializing field for member '{field.member_name}' in class '{clazz.__name__}' for " + f"'{self.orm_class.__name__}'") from e + + def _post_init_variants(self): + variant_field_id = None + if hasattr(self.orm_class, "__mapper_args__"): + # May be none + variant_field_id = self.orm_class.__mapper_args__.get("polymorphic_on") + + if variant_field_id is None: + return + + var_field = self._fields_by_variant_by_config_id[None].get(variant_field_id) + if var_field is None: + var_field = self._fields_by_variant_by_data_id[None].get(variant_field_id) + + if var_field is None: + raise Exception(f"Unknown variant field '{variant_field_id}' (determined by 'polymorphic_on' with config id, " + f"or data id if config id not found) for API class '{self.orm_class.__name__}' (Variant " + f"field may not be in a subclass!, and must be included in the config or data)") + if self.enable_config and self.config_allow_creation and not var_field.include_in_config: + raise Exception(f"Variant field '{variant_field_id}' for class '{self.orm_class.__name__}' must be included " + f"in the config if the config allows creation") + if not isinstance(var_field, ApiEnumField): + raise Exception(f"Variant field '{variant_field_id}' for class '{self.orm_class.__name__}' must be an " + f"enum!") + self._variant_field = var_field + if self._variant_field.may_be_none: + raise Exception(f"Variant field '{variant_field_id}' for class '{self.orm_class.__name__}' may not be " + f"nullable") + if self.enable_config and self.config_allow_creation and not self._variant_field.config_only_at_creation: + raise Exception( + f"Variant field '{variant_field_id}' for class '{self.orm_class.__name__}' must be only " + f"at creation") + + for variant_id in self._variant_field.str_enums: + self._fields_by_variant_by_config_id[variant_id] = {} + self._fields_by_variant_by_data_id[variant_id] = {} + + # Note that the base variant actually can't have any variant fields (All fields without a variant belong + # to it (and to all other variants)) + # The base variant is also optional + base_variant_id = _get_variant_id(self.orm_class, optional=True) + if base_variant_id is not None and base_variant_id not in self._variant_field.str_enums: + raise Exception(f"Class '{self.orm_class.__name__}' has unknown variant '{base_variant_id}'") + + def _post_init_variant_fields(self, all_classes: dict[str, "ApiObjectClass"]): + for sub_class in self.orm_class.__subclasses__(): + if not hasattr(sub_class, "__all_class_api_fields__"): + continue + if self._variant_field is None: + raise Exception(f"Found API subclass '{sub_class.__name__}' of API class '{self.orm_class.__name__}' " + f"but 'polymorphic_on' is missing in the orm '__mapper_args__'") + + variant_id = _get_variant_id(sub_class) + if variant_id not in self._variant_field.str_enums: + raise Exception(f"Unknown variant '{variant_id}' for class '{sub_class.__name__}' in enum " + f"'{self._variant_field.enum_class.__name__}'") + + # noinspection PyUnresolvedReferences + for sub_field in sub_class.__all_class_api_fields__: + assert isinstance(sub_field, ApiField) + try: + self._add_field(all_classes, sub_field, sub_class, variant_id) + except Exception as e: + raise Exception( + f"While initializing field for member '{sub_field.member_name}' in class '{sub_class.__name__}'") from e + + for sub2_class in filter( + lambda c: hasattr(c, "__all_class_api_fields__"), + recursive_flat_map( + lambda c: c.__subclasses__(), + sub_class.__subclasses__() + )): + raise Exception( + f"API class '{sub2_class.__name__}' is indirect child of API class '{self.orm_class.__name__}'." + f" Indirect inheritance is not supported (Only direct)") + + def _post_init_parents(self, all_classes: dict[str, "ApiObjectClass"]): + for parent_relation_id in (self._parent_relationship_config_ids or []): + parent_relation_attr = None + if hasattr(self.orm_class, parent_relation_id): + parent_relation_attr = getattr(self.orm_class, parent_relation_id) + + if parent_relation_attr is None: + raise TypeError(f"Unknown parent relationship id '{parent_relation_id}' for class " + f"'{self.orm_class.__name__}'") + parent_relation = parent_relation_attr.prop + if not isinstance(parent_relation, orm.Relationship): + raise TypeError(f"Parent relationship '{parent_relation_id}' for class '{self.orm_class.__name__}' " + f"is not a relationship") + + if parent_relation.collection_class is not None: + raise TypeError(f"Parent relationship '{parent_relation_id}' for class '{self.orm_class.__name__}' " + f"can reference more than one object") + + parent_class = get_relationship_foreign_class(all_classes, parent_relation) + if parent_class.id in self._parent_relationship_id_by_class_id: + raise ValueError(f"Multiple parent fields for parent class '{parent_class.id}' in class '{self.orm_class.__name__}' ") + self._parent_relationship_id_by_class_id[parent_class.id] = parent_relation_id + + def _post_init_creation_config(self): + self._creation_config_json = None + if not self.enable_config or not self.config_allow_creation: + return + + var_fields = { + variant_id: [ + field.config_get_description() + for field in var_fields.values() + if field is not self._variant_field + ] for variant_id, var_fields in self._fields_by_variant_by_config_id.items() + } + self._creation_config_json = { + "fields": var_fields[None] + } + if self._variant_field is not None: + var_fields.pop(None) + self._creation_config_json["variant_fields"] = var_fields + + def _add_field(self, + all_classes: dict[str, "ApiObjectClass"], + field: ApiField, + variant_class: type or None, + variant_id: str or None): + + attr_class = self.orm_class if variant_id is None else variant_class + + attr = None + if field.member_name is not None and hasattr(attr_class, field.member_name): + attr = getattr(attr_class, field.member_name) + + field = field.pre_post_init_copy() + context = FieldContext( + all_classes=all_classes, + api_class=self, + own_member=attr, + variant_id=variant_id + ) + field.post_init(context) + field.post_init_check(context) + + if isinstance(field, ApiConfigField) and field.include_in_config: + fields_by_id = self._fields_by_variant_by_config_id[variant_id] + if field.config_id in fields_by_id: + raise ValueError(f"Config id conflict with '{field.config_id}' in variant '{variant_id}'") + fields_by_id[field.config_id] = field + if variant_id is not None: + fields_by_id = self._fields_by_variant_by_config_id[None] + if field.config_id in fields_by_id: + raise ValueError(f"Config id conflict with '{field.config_id}' in variant '{variant_id}' and non-variant") + + if isinstance(field, ApiDataField) and field.include_in_data: + fields_by_id = self._fields_by_variant_by_data_id[variant_id] + if field.data_id in fields_by_id: + raise ValueError(f"Data id conflict with '{field.data_id}' in variant '{variant_id}'") + fields_by_id[field.data_id] = field + if variant_id is not None: + fields_by_id = self._fields_by_variant_by_data_id[None] + if field.data_id in fields_by_id: + raise ValueError(f"Data id conflict with '{field.data_id}' in variant '{variant_id}' and non-variant") + + def _get_current_variant(self, obj) -> str or None: + if self._variant_field is None: + return None + assert isinstance(self._variant_field, ApiEnumField) + variant_id = self._variant_field.config_get_value(obj) # For enums the same as data_get_value + if variant_id not in self._variant_field.str_enums: + raise ValueError(f"Unknown variant for class '{self.id}' in database: '{variant_id}' (object id: {obj.id})") + return variant_id + + def _get_current_variant_set(self, obj) -> set[str or None]: + return {self._get_current_variant(obj), None} + + def get_variants(self) -> list[str] or None: + if self._variant_field is None: + return None + return self._variant_field.str_enums + + def get_data_fields(self) -> list[ApiDataField]: + return list(flat_map(lambda by_id: by_id.values(), self._fields_by_variant_by_data_id.values())) + + def get_config_fields(self) -> list[ApiConfigField]: + return list(flat_map(lambda by_id: by_id.values(), self._fields_by_variant_by_config_id.values())) + + def get_data_fields_with_id(self, data_id: str) -> list[ApiDataField]: + field_list = [] + for by_id in self._fields_by_variant_by_data_id.values(): + field = by_id.get(data_id) + if field is not None: + field_list.append(field) + return field_list + + def get_config_fields_with_id(self, config_id: str) -> list[ApiConfigField]: + field_list = [] + for by_id in self._fields_by_variant_by_config_id.values(): + field = by_id.get(config_id) + if field is not None: + field_list.append(field) + return field_list + + def serialize(self, obj, **kwargs) -> dict: + if not self.enable_data: + raise Exception("Serialization not enabled") + try: + return self.serialize_object_args(obj, ArgAttributeObject(**kwargs)) + except AttributeError as e: + if not str(e).startswith("'ArgAttributeObject' object has no attribute"): + raise e + raise AttributeError(f"Missing a keyword argument for serialize() of object '{self.id}': " + str(e)) from e + + def serialize_object_args(self, obj, args) -> dict: + if not self.enable_data: + raise Exception("Serialization not enabled") + res = {} + for variant_id in self._get_current_variant_set(obj): + for field in self._fields_by_variant_by_data_id[variant_id].values(): + field.add_to_data(obj, res, args) + return res + + def get_creation_config(self) -> JsonTypes or None: + if not self.enable_config: + raise Exception("Config not enabled") + return self._creation_config_json + + def is_deletion_allowed(self) -> bool: + return issubclass(self.orm_class, DeletableApiObject) + + def get_current_config(self, session: SessionDb, object_id: int): + if not self.enable_config: + raise Exception("Config not enabled") + obj = session.scalar( + self.orm_class.basic_select() + .where(self.sql_id_column == object_id) + ) + if obj is None: + raise ApiClientException(ERROR_UNKNOWN_OBJECT) + + config = { + "fields": [ + field.config_get_value_with_description(obj) + for field in flat_map( + lambda var_id: self._fields_by_variant_by_config_id[var_id].values(), + self._get_current_variant_set(obj) + ) + if not field.config_only_at_creation + ] + } + session.rollback() + return config + + def modify_current_config(self, + session: SessionDb, + modifying_user_id: int, + object_id: int, + expected_old_values: CJsonObject, + new_values: CJsonObject): + if not self.enable_config: + raise Exception("Config not enabled") + obj = session.scalar( + self.orm_class.basic_select() + .where(self.sql_id_column == object_id) + ) + if obj is None: + raise ApiClientException(ERROR_UNKNOWN_OBJECT) + + variant_id = self._get_current_variant(obj) + + from videoag_common.objects import ChangelogModificationEntry + + expected_keys = set(expected_old_values.keys()) + for field_id in new_values.keys(): + new_value = new_values.get(field_id) + + field = self._fields_by_variant_by_config_id[variant_id].get(field_id) + if field is None and variant_id is not None: + field = self._fields_by_variant_by_config_id[None].get(field_id) + + if field is None: + new_value.raise_error(f"Unknown field (object variant is: '{variant_id}')") + + if field.config_only_at_creation: + new_value.raise_error("Field may only be set at creation") + + expected_old = expected_old_values.get(field_id, optional=True) + + old_value_json = field.config_get_value(obj) + if expected_old is not None and not expected_old.equals_json(old_value_json): + raise ApiClientException(ERROR_MODIFICATION_UNEXPECTED_CURRENT_VALUE) + + field.config_set_value(session, obj, new_value) + + new_value_json = field.config_get_value(obj) + + if old_value_json != new_value_json: + session.add(ChangelogModificationEntry( + modifying_user_id=modifying_user_id, + object_type=self.id, + object_id=obj.id, + field_id=field.config_id, + old_value=old_value_json, + new_value=new_value_json, + )) + expected_keys.discard(field_id) + + if len(expected_keys) > 0: + expected_old_values.raise_error(f"No new value was set for fields '{expected_keys}' which have expected old values") + + def create_new_object(self, + session: SessionDb, + modifying_user_id: int, + parent_class_id: str or None, + parent_id: int or None, + variant_id: str or None, + values: CJsonObject) -> int: + if not self.enable_config: + raise Exception("Config not enabled") + if not self.config_allow_creation: + raise Exception("Creation not allowed") + + obj = self.orm_class() + parent_class = None + if len(self._parent_relationship_id_by_class_id) > 0: + if parent_class_id is None: + raise ApiClientException(ERROR_OBJECT_ERROR("Missing parent type")) + if parent_id is None: + raise ApiClientException(ERROR_OBJECT_ERROR("Missing parent id")) + if parent_class_id not in self._parent_relationship_id_by_class_id: + raise ApiClientException(ERROR_OBJECT_ERROR("Unknown parent type")) + from ..objects import API_CLASSES_BY_ID + parent_class = API_CLASSES_BY_ID[parent_class_id] + parent_relationship_id = self._parent_relationship_id_by_class_id[parent_class_id] + + parent_obj = session.scalar( + parent_class.orm_class.basic_select() + .where(parent_class.sql_id_column == parent_id) + ) + if parent_obj is None: + raise ApiClientException(ERROR_OBJECT_ERROR("Unknown parent object")) + setattr(obj, parent_relationship_id, parent_obj) + else: + if parent_class_id is not None or parent_id is not None: + raise ApiClientException(ERROR_OBJECT_ERROR("This object may have no parent")) + + if self._variant_field is not None: + if variant_id is None: + raise ApiClientException(ERROR_OBJECT_ERROR("Missing object variant")) + if variant_id not in self._variant_field.str_enums: + raise ApiClientException(ERROR_OBJECT_ERROR("Unknown object variant")) + self._variant_field.config_set_value(session, obj, CJsonValue(variant_id)) + else: + if variant_id is not None: + raise ApiClientException(ERROR_OBJECT_ERROR("This object may have no variant")) + + from videoag_common.objects import ChangelogModificationEntry, ChangelogCreationEntry + changelog_entries: list[ChangelogModificationEntry] = [] + + remaining_value_keys = set(values.keys()) + for variant_id in {variant_id, None}: + for field in self._fields_by_variant_by_config_id[variant_id].values(): + if field is self._variant_field: + continue + + remaining_value_keys.discard(field.config_id) + + if values.has(field.config_id): + field_value = values.get(field.config_id) + else: + field_value = field.config_get_default() + if field_value is None: + raise ApiClientException(ERROR_OBJECT_ERROR(f"Missing value for field '{field.config_id}'")) + if field_value == (None,): + field_value = None + field_value = CJsonValue(field_value) + + field.config_set_value(session, obj, field_value) + + new_value_json = field.config_get_value(obj) + + from videoag_common.objects import ChangelogModificationEntry + changelog_entries.append(ChangelogModificationEntry( + modifying_user_id=modifying_user_id, + object_type=self.id, + field_id=field.config_id, + old_value=None, + new_value=new_value_json, + )) + + if len(remaining_value_keys) > 0: + values.raise_error(f"Unknown fields: '{remaining_value_keys}' (May only be available in other variants)") + + session.add(obj) + session.flush() + + for entry in changelog_entries: + entry.object_id = obj.id + session.add(entry) + + session.add(ChangelogCreationEntry( + modifying_user_id=modifying_user_id, + object_type=self.id, + object_id=obj.id, + parent_type=None if parent_class is None else parent_class.id, + parent_id=None if parent_class is None else parent_id, + variant=variant_id + )) + session.flush() + return obj.id + + def set_deletion(self, + session: SessionDb, + modifying_user_id: int, + object_id: int, + new_deleted: bool): + if not self.enable_config: + raise Exception("Config not enabled") + if not self.is_deletion_allowed(): + raise Exception("Deletion not allowed") + if new_deleted: + query = ( + self.orm_class.basic_select() + .where(self.sql_id_column == object_id) + ) + else: + query = ( + sql.select(self.orm_class) + .where(self.sql_id_column == object_id) + ) + obj = session.scalar(query) + if obj is None: + raise ApiClientException(ERROR_UNKNOWN_OBJECT) + obj.deleted = new_deleted + from videoag_common.objects import ChangelogDeletionChangeEntry + session.add(ChangelogDeletionChangeEntry( + modifying_user_id=modifying_user_id, + object_type=self.id, + object_id=obj.id, + is_now_deleted=new_deleted + )) + + +def _check_no_api_object_subclass(clazz: type): + if issubclass(clazz, ApiObject): + raise Exception(f"Class '{clazz}' is a sub class of '{ApiObject}' but the ORM Base class is inherited by an " + f"(indirect) parent. This is not supported") + for subclass in clazz.__subclasses__(): + _check_no_api_object_subclass(subclass) diff --git a/src/videoag_common/api_object/util.py b/src/videoag_common/api_object/util.py new file mode 100644 index 0000000000000000000000000000000000000000..fb09332a04221a11f2457118a0d6ee237d3342c5 --- /dev/null +++ b/src/videoag_common/api_object/util.py @@ -0,0 +1,9 @@ +from videoag_common.database import * + + +def get_relationship_foreign_class(all_classes: dict[str, "ApiObjectClass"], relationship: orm.Relationship): + foreign_table_name = relationship.target.name + if foreign_table_name not in all_classes: + raise ValueError( + f"Relationship '{relationship}' references table which is not an API object: '{foreign_table_name}'") + return all_classes[foreign_table_name] diff --git a/src/videoag_common/database/__init__.py b/src/videoag_common/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2d37ebd0240bb2cbddc145a1c623596e142cf066 --- /dev/null +++ b/src/videoag_common/database/__init__.py @@ -0,0 +1,19 @@ +import sqlalchemy as sql +import sqlalchemy.orm as orm + +from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method +from sqlalchemy.sql.base import ExecutableOption +from sqlalchemy.orm import Session as SessionDb, DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.types import Text, String, TIMESTAMP +from sqlalchemy import types, ForeignKey + +from .base import ( + Base, + create_enum_type, + STRING_COLLATION +) +from .database import ( + TransactionIsolationLevel, + TransactionConflictError, + Database, +) diff --git a/src/videoag_common/database/base.py b/src/videoag_common/database/base.py new file mode 100644 index 0000000000000000000000000000000000000000..15254d96715c557b9b66356e7dfb003c80578c79 --- /dev/null +++ b/src/videoag_common/database/base.py @@ -0,0 +1,61 @@ +# Imports are used by files in objects/ + +from enum import Enum +from typing import Any + +from sqlalchemy import types, orm +from sqlalchemy.ext.hybrid import hybrid_method +from sqlalchemy.orm import DeclarativeBase, relationship + +from videoag_common.miscellaneous import * + +STRING_COLLATION = "und-x-icu" + + +def create_enum_type(enum_class: type[Enum]): + return types.Enum( + enum_class, + values_callable=lambda x: [e.value for e in x], + name=alphanum_camel_case_to_snake_case(enum_class.__name__) + ) + + +class Base(DeclarativeBase): + + def __init_subclass__(cls, **kw: Any) -> None: + if not hasattr(cls, "__tablename__"): + cls.__tablename__ = alphanum_camel_case_to_snake_case(cls.__name__) + super().__init_subclass__(**kw) + + @hybrid_method + def hybrid_rel(self, relationship, attribute: str, *args, **kwargs): + """ + Helper method for hybrid methods. Can be used to specify conditions for relationships. + + Example: Consider a class 'Lecture' with an attribute 'course' and a class Course with an attribute 'deleted'. + When you want to test whether the lecture's course is deleted with a hybrid method (e.g. in Python and SQL), you + would need the expression 'self.course.deleted' for Python and 'self.course.has(Course.deleted)' for SQL. This + can be replaced with the single expression 'self.hybrid_rel(self.course, "deleted")' + + The attribute may also be a method. In that case, all other arguments to this method, are given to that method + """ + if isinstance(self, type): + if not isinstance(relationship, orm.InstrumentedAttribute): + raise TypeError("relationship must be orm attribute of self") + if not isinstance(relationship.prop, orm.Relationship): + raise TypeError("relationship must be a relationship") + + rel_class = relationship.prop.mapper.class_ + val = getattr(rel_class, attribute) + else: + val = getattr(relationship, attribute) + + if callable(val): + val = val(*args, **kwargs) + elif len(args) > 0 or len(kwargs) > 0: + raise ValueError("Cannot give arguments for non-function attribute") + + if isinstance(self, type): + return relationship.has(val) + else: + return val diff --git a/src/videoag_common/database/database.py b/src/videoag_common/database/database.py new file mode 100644 index 0000000000000000000000000000000000000000..b3b338030fdbe9c7e5c915b0c138c8aec3381cc3 --- /dev/null +++ b/src/videoag_common/database/database.py @@ -0,0 +1,224 @@ +import os +from enum import StrEnum +from typing import Callable, TypeVar, TypeVarTuple, Sequence + +import sqlalchemy +from sqlalchemy import create_engine, URL +from sqlalchemy.orm import Session as SessionDb + +from videoag_common.miscellaneous import * +from .base import Base +from .drift_detector import check_for_drift_and_migrate + + +_T = TypeVar("_T") +_A = TypeVarTuple("_A") + + +class TransactionIsolationLevel(StrEnum): + READ_UNCOMMITTED = "READ UNCOMMITTED" + READ_COMMITTED = "READ COMMITTED" + REPEATABLE_READ = "REPEATABLE READ" + SERIALIZABLE = "SERIALIZABLE" + + +class TransactionConflictError(Exception): + pass + + +class Database: + + def __init__(self, config: dict): + engine_type = config["engine"] + if engine_type != "postgres": + raise ValueError("The only supported engine is 'postgres'") + + engine_config = config[engine_type] + host = dict_get_check_type(engine_config, "host", str) + port = dict_get_check_type(engine_config, "port", int) + user = dict_get_check_type(engine_config, "user", str) + password = dict_get_check_type(engine_config, "password", str) + database = dict_get_check_type(engine_config, "database", str) + + auto_migration = dict_get_check_type(engine_config, "auto_migration", bool, False) + ignore_no_connection = dict_get_check_type(engine_config, "ignore_no_connection", bool, False) + if bool(os.environ.get("API_IGNORE_NO_DB_CONNECTION", "false")): + ignore_no_connection = True + + self._max_read_attempts = dict_get_check_type(engine_config, "max_read_attempts", int, 2) + self._max_write_attempts = dict_get_check_type(engine_config, "max_write_attempts", int, 2) + if self._max_read_attempts < 1 or self._max_write_attempts < 1: + raise ValueError("Maximum read/write attempts must be >= 1") + + assert isinstance(user, str) + assert isinstance(password, str) + if dict_get_check_type(engine_config, "user_password_from_env", bool, False): + user = os.environ[user] + password = os.environ[password] + + self._engine = create_engine(URL.create( + "postgresql+psycopg", + username=user, + password=password, + host=host, + port=port, + database=database + )) + + self._read_engines_by_level = {} + self._write_engines_by_level = {} + for isolation_level in TransactionIsolationLevel: + self._read_engines_by_level[isolation_level] = self._engine.execution_options( + isolation_level=isolation_level.value, + postgresql_readonly=True + ) + self._write_engines_by_level[isolation_level] = self._engine.execution_options( + isolation_level=isolation_level.value, + postgresql_readonly=False + ) + + # Ensure objects are loaded + import videoag_common.objects + drifted = False + try: + drifted = not check_for_drift_and_migrate(Base.metadata, self._engine, auto_migration) + except Exception as e: + if not ignore_no_connection: + raise e + print(f"Exception while checking for drift. ignore_no_connection is set. Exception: {e}") + + if drifted: + raise Exception("Database schema has drifted!") + + def query_all_and_expunge(self, stmt: sqlalchemy.Select[_T]) -> Sequence[_T]: + def _trans(session: SessionDb): + res = session.scalars(stmt).all() + session.expunge_all() + session.rollback() + return res + return self.execute_read_transaction(_trans) + + def query_one_and_expunge(self, stmt: sqlalchemy.Select[_T]) -> _T: + def _trans(session: SessionDb): + res = session.scalars(stmt).one() + session.expunge_all() + session.rollback() + return res + return self.execute_read_transaction(_trans) + + def query_one_or_none_and_expunge(self, stmt: sqlalchemy.Select[_T]) -> _T or None: + def _trans(session: SessionDb): + res = session.scalars(stmt).one_or_none() + session.expunge_all() + session.rollback() + return res + return self.execute_read_transaction(_trans) + + def execute_read_transaction(self, + function: Callable[[SessionDb, *_A], _T], + *args: *_A, + isolation_level: TransactionIsolationLevel = TransactionIsolationLevel.REPEATABLE_READ + ) -> _T: + """ + Executes a read transaction with the given function. The function may be called multiple times if the read + transaction fails due to conflicts with other transaction. + + Note that the specified isolation level is only a minimum. A higher isolation level may be used for the transaction. + + May raise :class:`NoAvailableConnectionError` + """ + return self._execute_transaction(False, function, *args, isolation_level=isolation_level) + + def execute_write_transaction_and_commit(self, + function: Callable[[SessionDb, *_A], _T], + *args: *_A, + isolation_level: TransactionIsolationLevel = TransactionIsolationLevel.SERIALIZABLE + ) -> _T: + """ Does the same as execute_write_transaction but commits the session automatically """ + def _trans(session: SessionDb, *trans_args) -> _T: + res = function(session, *trans_args) + session.commit() + return res + return self.execute_write_transaction(_trans, *args, isolation_level=isolation_level) + + def execute_write_transaction(self, + function: Callable[[SessionDb, *_A], _T], + *args: *_A, + isolation_level: TransactionIsolationLevel = TransactionIsolationLevel.SERIALIZABLE + ) -> _T: + """ + Executes a read-write transaction with the given function. The function may be called multiple times if the write + transaction fails due to conflicts with other transaction. The function is never called again if the transaction + committed successfully + + Note that the specified isolation level is only a minimum. A higher isolation level may be used for the transaction. + + May raise :class:`NoAvailableConnectionError` + """ + return self._execute_transaction(True, function, *args, isolation_level=isolation_level) + + def _execute_transaction(self, + writeable: bool, + function: Callable[[SessionDb, *_A], _T], + *args: *_A, + isolation_level: TransactionIsolationLevel = TransactionIsolationLevel.SERIALIZABLE + ) -> _T: + attempts = 0 + while True: + attempts += 1 + transaction = None + try: + engine = (self._write_engines_by_level if writeable else self._read_engines_by_level)[isolation_level] + session = SessionDb(bind=engine) + transaction = session.begin() + result = function(session, *args) + if transaction.is_active: + msg = ("Rolling open transaction back after execution but there was no exception (Always " + "commit/rollback the transaction explicitly)") + if writeable: + raise Exception(msg) + # noinspection PyBroadException + try: + print("Warning: " + msg) + transaction.rollback() + except Exception: + pass + return result + except Exception as e: + is_conflict = Database._is_transaction_conflict_exception(e) + + if transaction is not None and transaction.is_active: + if not is_conflict: + self._on_transaction_aborted_by_user(writeable) + # noinspection PyBroadException + try: + transaction.rollback() + except: + pass + + if not is_conflict: + raise e + + self._on_transaction_conflict(writeable) + if attempts >= (self._max_write_attempts if writeable else self._max_read_attempts): + self._on_transaction_aborted_after_repeated_conflict(writeable) + raise TransactionConflictError(e) + continue + + raise AssertionError("This should be unreachable code") + + @staticmethod + def _is_transaction_conflict_exception(e: Exception) -> bool: + return "could not serialize access" in str(e) + + def _on_transaction_started(self, writeable: bool): + pass + + def _on_transaction_aborted_by_user(self, writeable: bool): + pass + + def _on_transaction_conflict(self, writeable: bool): + pass + + def _on_transaction_aborted_after_repeated_conflict(self, writable: bool): + pass diff --git a/src/videoag_common/database/drift_detector.py b/src/videoag_common/database/drift_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..6094610a893d0498368eda200eb76016bb8cd512 --- /dev/null +++ b/src/videoag_common/database/drift_detector.py @@ -0,0 +1,378 @@ +import sys +import traceback +from typing import Callable + +from sqlalchemy import Engine, Table, MetaData, Column, types, DefaultClause, TextClause, Constraint, CheckConstraint, \ + ForeignKeyConstraint, UniqueConstraint, PrimaryKeyConstraint +from sqlalchemy.exc import * +from sqlalchemy.sql.base import _NoneName, ReadOnlyColumnCollection +from sqlalchemy.sql.schema import ColumnCollectionConstraint, ForeignKey + + +# +# This file attempts its best to detect any difference between the schema in the python files and the actual database. +# 'Simple' differences can also be fixed. This includes: +# - Adding a missing table # TODO +# - Adding a missing type +# - Adding a missing column +# +# The following aspects are currently being checked with varying accuracy +# - Table names +# - Columns +# - Name +# - Type +# - Nullable +# - Primary Key +# - Auto Increment +# - Server default value (only if set in python schema, for autoincrement) +# - Unique +# +# A lot of the code here is 'a bit messy' due to all the different dialects (This only really needs to work for postgres +# but SQLAlchemy is designed to work with all of them). Especially the type comparison. If you use new types, you might +# need to tweak some stuff here +# + + +# Improved string output +def _to_string(value) -> str: + match value: + case DefaultClause() as clause: + return f"DefaultClause({_to_string(clause.arg)}, for_update={clause.for_update})" + + case TextClause() as clause: + return f"TextClause(text='{clause.text}')" # Because TextClause doesn't print its text by default + + case PrimaryKeyConstraint() as constraint: + assert isinstance(constraint, PrimaryKeyConstraint) + return f"PrimaryKeyConstraint({_to_string(constraint.columns)}, table.name={constraint.table.name})" + + case CheckConstraint() as constraint: + assert isinstance(constraint, CheckConstraint) + return f"PrimaryKeyConstraint({_to_string(constraint.columns)}, sqltext={constraint.sqltext} table.name={constraint.table.name})" + + case UniqueConstraint() as constraint: + assert isinstance(constraint, UniqueConstraint) + return f"PrimaryKeyConstraint({_to_string(constraint.columns)}, table.name={constraint.table.name})" + + case ForeignKeyConstraint() as constraint: + assert isinstance(constraint, ForeignKeyConstraint) + return (f"ForeignKeyConstraint(" + f"columns={_to_string(constraint.columns)}, " + f"elements={_to_string(constraint.elements)}, " + f"onupdate={constraint.onupdate}, " + f"ondelete={constraint.ondelete}, " + f"ondelete={constraint.match}, " + f"table.name={constraint.table.name})") + + case ReadOnlyColumnCollection() as cols: + return "[" + ",".join(map(lambda c: repr(c), cols)) + "]" + + case _: + return repr(value) + + +def _check_columns_equal(first: ReadOnlyColumnCollection[str, Column], + second: ReadOnlyColumnCollection[str, Column]) -> bool: + if len(first) != len(second): + return False + + # TODO how does this work with columns of multiple tables? Can the collection map one key to multiple columns? + for column in first: + if column.name not in second: + return False + if second[column.name].table.name != column.table.name: + return False + + return True + + +def _check_constraint_equal(actual_constraint: Constraint, schema_constraint: Constraint) -> bool: + if (actual_constraint.name is not None + and schema_constraint.name is not None + and actual_constraint.name != schema_constraint.name): + return False + + if not isinstance(schema_constraint, ColumnCollectionConstraint): + print(f"Unknown type of constraint {schema_constraint}. Can't compare") + return False + + if not isinstance(actual_constraint, ColumnCollectionConstraint): + return False + + if not _check_columns_equal(actual_constraint.columns, schema_constraint.columns): + return False + + match schema_constraint: + case CheckConstraint(): + assert isinstance(schema_constraint, CheckConstraint) # For pycharm + + if not isinstance(actual_constraint, CheckConstraint): + return False + if schema_constraint.sqltext != actual_constraint.sqltext: # TODO test if this works + return False + return True + case ForeignKeyConstraint(): + assert isinstance(schema_constraint, ForeignKeyConstraint) # For pycharm + + if not isinstance(actual_constraint, ForeignKeyConstraint): + return False + + # TODO elements? + return (schema_constraint.onupdate == actual_constraint.onupdate + and schema_constraint.ondelete == actual_constraint.ondelete + and schema_constraint.match == actual_constraint.match) + case PrimaryKeyConstraint(): + return isinstance(actual_constraint, PrimaryKeyConstraint) + case UniqueConstraint(): + return isinstance(actual_constraint, UniqueConstraint) + case _: + print(f"Unknown type of constraint {schema_constraint}. Can't compare") + return False + + +def _check_types_equal(actual: types.TypeEngine, schema: types.TypeEngine) -> bool: + if type(schema) is types.Integer: + return isinstance(actual, types.Integer) + + if type(schema) is types.SmallInteger: + return isinstance(actual, types.SmallInteger) + + if type(schema) is types.BigInteger: + return isinstance(actual, types.BigInteger) + + if type(schema) is types.Float: + return isinstance(actual, types.Float) + + if type(schema) is types.Double: + return isinstance(actual, types.Double) + + if type(schema) is types.Boolean: + return isinstance(actual, types.Boolean) + + if type(schema) is types.String: + # TODO Check collation. Right now sqlalchemy does return the collation via reflection (https://github.com/sqlalchemy/sqlalchemy/issues/6511) + assert isinstance(schema, types.String) # For pycharm + return (isinstance(actual, types.VARCHAR) + and actual.length == schema.length) + + if type(schema) is types.Text: + # Check collation. See above + assert isinstance(schema, types.Text) # For pycharm + return (isinstance(actual, types.Text) + and actual.length == schema.length) + + if type(schema) is types.JSON: + assert isinstance(schema, types.JSON) + return (isinstance(actual, types.JSON) + and schema.none_as_null == actual.none_as_null) + + if type(schema) is types.DateTime: + assert isinstance(schema, types.DateTime) # For pycharm + return (isinstance(actual, types.DateTime) + and actual.timezone == schema.timezone) + + if type(schema) is types.Date: + return isinstance(actual, types.Date) + + if type(schema) is types.TIMESTAMP: + return isinstance(actual, types.TIMESTAMP) + + if isinstance(schema, types.Enum): + if not isinstance(actual, types.Enum): + return False + if not schema.name: + print(f"Enum '{schema}' in schema has no name. Can't compare") + return False + if not schema.values_callable: + print(f"Enum '{schema}' in schema has no values_callable. Can't compare") + return False + # Name in actual not required because of sqlite + return (not actual.name or schema.name == actual.name) and schema.enums == actual.enums + + raise RuntimeError(f"Comparison of types '{_to_string(actual)}' and '{_to_string(schema)}' is not supported") + + +def _check_default_clause_equal(actual: DefaultClause, schema: DefaultClause) -> bool: + if schema is None: + return (actual is None or + (isinstance(actual.arg, TextClause) and actual.arg.text.startswith("NULL::"))) + + if actual is None: + return False + + if actual.for_update != schema.for_update: + return False + + # TODO check other attributes? + if isinstance(schema.arg, str): + return actual.arg.text == schema.arg or actual.arg.text == f"'{schema.arg}'" + elif isinstance(schema.arg, TextClause): + return actual.arg.text == schema.arg.text + else: + raise ValueError(f"Unable to compare default clause '{_to_string(actual)}' with '{_to_string(schema)}'") + + +def _get_actual_autoincrement(column: Column) -> bool: + match column.autoincrement: + case False: + return False + case True: + return True + case "auto": + return (column.default is None + and column.server_default is None + and column.primary_key + and not any(map(lambda c: c is not column and c.primary_key, column.table.columns)) + and _check_types_equal(column.type, types.Integer()) + ) + case _: + raise ValueError(f"Unknown value for autoincrement: {column.autoincrement}") + + +def _check_column_autoincrement( + actual_column: Column, + schema_column: Column) -> bool: + actual_autoincrement = _get_actual_autoincrement(actual_column) + schema_autoincrement = _get_actual_autoincrement(schema_column) + if actual_autoincrement != schema_autoincrement: + print(f"Table '{actual_column.table.name}': Column '{actual_column.name}' has 'autoincrement' value " + f"'{_to_string(actual_column.autoincrement)}' in database but should have '{_to_string(schema_column.autoincrement)}'") + return False + return True + + +def _check_column_server_default_with_autoincrement(actual_column: Column, schema_column: Column) -> bool: + if actual_column.server_default is not None and not isinstance(actual_column.server_default, DefaultClause): + print(f"Table '{actual_column.table}': Column '{actual_column.name}' has server_default value in database which " + f"is not of type DefaultClause. Can't compare") + return False + + if schema_column.server_default is not None and not isinstance(schema_column.server_default, DefaultClause): + print(f"Table '{schema_column.table}': Column '{schema_column.name}' has server_default value in schema which " + f"is not of type DefaultClause. Can't compare") + return False + + if schema_column.server_default is not None: + if _check_default_clause_equal(actual_column.server_default, schema_column.server_default): + return True + + print( + f"Table '{actual_column.table}': Column '{actual_column.name}' has server_default " + f"'{_to_string(actual_column.server_default)}' but should have '{_to_string(schema_column.server_default)}'") + return False + + if not _get_actual_autoincrement(schema_column): + return True + + if (actual_column.server_default + and isinstance(actual_column.server_default.arg, TextClause) + and actual_column.server_default.arg.text.startswith("nextval(")): + return True + + print(f"Table '{actual_column.table}': Column '{actual_column.name}' has autoincrement enabled but server default " + f"value is not nextval() function in database") + return False + + +def _check_column_attribute( + actual_column: Column, + schema_column: Column, + attr_name: str, + comparator: Callable[[object, object], bool] = lambda a, s: a == s) -> bool: + actual_attr = getattr(actual_column, attr_name) + schema_attr = getattr(schema_column, attr_name) + if not comparator(actual_attr, schema_attr): + print(f"Table '{actual_column.table.name}': Column '{actual_column.name}' has '{attr_name}' value " + f"'{_to_string(actual_attr)}' in database but should have '{_to_string(schema_attr)}'") + return False + return True + + +def _check_table_equal(actual_table: Table, schema_table: Table) -> bool: + correct = True + if actual_table.name != schema_table.name: + correct = False + print(f"Actual table has name '{actual_table.name}' but should have '{schema_table.name}'") + + for column_name, actual_column in actual_table.columns.items(): + if column_name not in schema_table.columns: + if actual_column.server_default is None: + correct = False + print( + f"Table '{actual_table.name}': Database contains unknown column '{column_name}' which does not have a default value") + continue + schema_column = schema_table.columns[column_name] + + # Note that 'default' is only a client-side attribute! + assert isinstance(actual_column, Column) + correct &= _check_column_autoincrement(actual_column, schema_column) + correct &= _check_column_server_default_with_autoincrement(actual_column, schema_column) + correct &= _check_column_attribute(actual_column, schema_column, "computed") + correct &= _check_column_attribute(actual_column, schema_column, "identity") + # TODO key + correct &= _check_column_attribute(actual_column, schema_column, "nullable") + correct &= _check_column_attribute(actual_column, schema_column, "onupdate") + correct &= _check_column_attribute(actual_column, schema_column, "primary_key") + correct &= _check_column_attribute(actual_column, schema_column, "server_onupdate") + correct &= _check_column_attribute(actual_column, schema_column, "system") + # noinspection PyTypeChecker + correct &= _check_column_attribute(actual_column, schema_column, "type", _check_types_equal) + correct &= _check_column_attribute(actual_column, schema_column, "unique") + + for column_name, schema_column in schema_table.columns.items(): + if column_name not in actual_table.columns: + correct = False + print(f"Missing column '{column_name}' in database") + + for schema_constraint in schema_table.constraints: + if not any(map(lambda c: _check_constraint_equal(c, schema_constraint), actual_table.constraints)): + correct = False + print(f"Missing constraint\n {_to_string(schema_constraint)}\nin database. The following constraints do not match:") + print("\n".join(map(lambda c: f" {_to_string(c)}", actual_table.constraints))) + + return correct + + +def check_for_drift_and_migrate(metadata: MetaData, engine: Engine, migrate: bool = False) -> bool: + correct = True + actual_metadata = MetaData() + + if migrate: + print("Letting SQLAlchemy create missing entities...") + import logging + sqlalchemy_logger = logging.getLogger("sqlalchemy.engine") + + own_handler = logging.StreamHandler(sys.stdout) + own_handler.setLevel(logging.INFO) + + def _filter(record: logging.LogRecord) -> bool: + msg = record.getMessage().upper() + return not msg.startswith("SELECT") + + own_handler.addFilter(_filter) + + old_level = sqlalchemy_logger.level + sqlalchemy_logger.setLevel(logging.INFO) + sqlalchemy_logger.addHandler(own_handler) + + metadata.create_all(engine) + + sqlalchemy_logger.removeHandler(own_handler) + sqlalchemy_logger.setLevel(old_level) + print(f"SQLAlchemy is done. See log for executed statements (selects excluded)") + + print(f"Checking schema against actual entities") + for table_name, schema_table in metadata.tables.items(): + assert isinstance(schema_table, Table) + try: + actual_table = Table(table_name, actual_metadata, autoload_with=engine) + correct &= _check_table_equal(actual_table, schema_table) + except NoSuchTableError: + correct = False + if migrate: + print(f"Missing table '{table_name}' in database (Migration is enabled but SQLAlchemy did not create this table for some reason)") + else: + print(f"Missing table '{table_name}' in database (Migration is disabled)") + + print(f"Drift detection is done. Schema {'is ok' if correct else 'has drifted'}") + return correct diff --git a/src/videoag_common/miscellaneous/__init__.py b/src/videoag_common/miscellaneous/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..201960477e28eecc479fe469680dc63d0c0c8e67 --- /dev/null +++ b/src/videoag_common/miscellaneous/__init__.py @@ -0,0 +1,45 @@ +from .util import ( + JsonTypes, + ID_STRING_REGEX_NO_LENGTH, + ID_STRING_PATTERN_NO_LENGTH, + DEFAULT_DATETIME_FORMAT, + ArgAttributeObject, + _raise, + truncate_string, + flat_map, + recursive_flat_map, + recursive_flat_map_single, + dict_get_check_type, + alphanum_camel_case_to_snake_case, +) +from .constants import * +from .errors import ( + ApiError, + ApiClientException, + ERROR_BAD_REQUEST, + ERROR_REQUEST_MISSING_PARAMETER, + ERROR_REQUEST_INVALID_PARAMETER, + ERROR_AUTHENTICATION_NOT_AVAILABLE, + ERROR_OBJECT_ERROR, + ERROR_METHOD_NOT_ALLOWED, + ERROR_MALFORMED_JSON, + ERROR_UNKNOWN_REQUEST_PATH, + ERROR_INTERNAL_SERVER_ERROR, + ERROR_LECTURE_HAS_NO_PASSWORD, + ERROR_AUTHENTICATION_FAILED, + ERROR_UNAUTHORIZED, + ERROR_INVALID_CSRF_TOKEN, + ERROR_ACCESS_FORBIDDEN, + ERROR_RATE_LIMITED, + ERROR_UNKNOWN_OBJECT, + ERROR_MODIFICATION_UNEXPECTED_CURRENT_VALUE, + ERROR_TOO_MANY_SUGGESTIONS, + ERROR_SITE_IS_READONLY, + ERROR_SITE_IS_DISABLED, + ERROR_SITE_IS_OVERLOADED +) +from .json import ( + CJsonValue, + CJsonObject, + CJsonArray, +) \ No newline at end of file diff --git a/src/videoag_common/miscellaneous/constants.py b/src/videoag_common/miscellaneous/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..60c95a86c88fc94abda7ce7c5d6494850485689b --- /dev/null +++ b/src/videoag_common/miscellaneous/constants.py @@ -0,0 +1,34 @@ +MIN_VALUE_SINT32 = -2147483648 +MAX_VALUE_SINT32 = 2147483647 +MIN_VALUE_UINT32 = 0 +MAX_VALUE_UINT32 = 4294967295 +MIN_VALUE_SINT64 = -9223372036854775808 +MAX_VALUE_SINT64 = 9223372036854775807 +MIN_VALUE_UINT64 = 0 +MAX_VALUE_UINT64 = 18446744073709551615 + + +# Non-exhaustive list of HTTP status codes +HTTP_200_OK = 200 +HTTP_201_CREATED = 201 +HTTP_202_ACCEPTED = 202 +HTTP_300_MULTIPLE_CHOICES = 300 +HTTP_301_MOVED_PERMANENTLY = 301 +HTTP_302_FOUND = 302 +HTTP_303_SEE_OTHER = 303 +HTTP_304_NOT_MODIFIED = 304 +HTTP_307_TEMPORARY_REDIRECT = 307 +HTTP_308_PERMANENT_REDIRECT = 308 +HTTP_400_BAD_REQUEST = 400 +HTTP_401_UNAUTHORIZED = 401 +HTTP_403_FORBIDDEN = 403 +HTTP_404_NOT_FOUND = 404 +HTTP_405_METHOD_NOT_ALLOWED = 405 +HTTP_406_NOT_ACCEPTABLE = 406 +HTTP_408_REQUEST_TIMEOUT = 408 +HTTP_409_CONFLICT = 409 +HTTP_429_TOO_MANY_REQUESTS = 429 +HTTP_500_INTERNAL_SERVER_ERROR = 500 +HTTP_501_NOT_IMPLEMENTED = 501 +HTTP_502_BAD_GATEWAY = 502 +HTTP_503_SERVICE_UNAVAILABLE = 503 diff --git a/src/videoag_common/miscellaneous/errors.py b/src/videoag_common/miscellaneous/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..0ca718b2c381c9dd7209ff4180246f8c81b3e8bc --- /dev/null +++ b/src/videoag_common/miscellaneous/errors.py @@ -0,0 +1,95 @@ +from .constants import * + + +class ApiError: + + def __init__(self, error_code: str, http_status_code: int, message: str): + self.error_code = error_code + self.http_status_code = http_status_code + self.message = message + + +class ApiClientException(Exception): + + def __init__(self, error: ApiError): + self.error = error + + +def ERROR_BAD_REQUEST(message: str = "The request is invalid") -> ApiError: + return ApiError("bad_request", HTTP_400_BAD_REQUEST, message) + + +def ERROR_REQUEST_MISSING_PARAMETER(parameter_name: str) -> ApiError: + return ApiError("request_missing_parameter", HTTP_400_BAD_REQUEST, + "Missing parameter " + parameter_name + " in request") + + +def ERROR_REQUEST_INVALID_PARAMETER(parameter_name: str, invalid_message: str) -> ApiError: + return ApiError("request_invalid_parameter", HTTP_400_BAD_REQUEST, + "Parameter " + parameter_name + " in request is invalid: " + invalid_message) + + +def ERROR_AUTHENTICATION_NOT_AVAILABLE(error_message: str = "Authentication servers are currently not available") -> ApiError: + return ApiError("authentication_not_available", HTTP_503_SERVICE_UNAVAILABLE, error_message) + + +def ERROR_OBJECT_ERROR(message: str) -> ApiError: + return ApiError("object_error", HTTP_400_BAD_REQUEST, + message) + + +ERROR_METHOD_NOT_ALLOWED = ApiError("method_not_allowed", HTTP_405_METHOD_NOT_ALLOWED, + "The specified request method is not allowed") +ERROR_MALFORMED_JSON = ApiError("malformed_json", HTTP_400_BAD_REQUEST, + "The request json is malformed or missing (Or Content-Type is not application/json)") +ERROR_UNKNOWN_REQUEST_PATH = ApiError("unknown_request_path", HTTP_400_BAD_REQUEST, + "The specified request path does not exist") +ERROR_INTERNAL_SERVER_ERROR = ApiError("internal_server_error", HTTP_500_INTERNAL_SERVER_ERROR, + "An unknown internal server error occurred") +ERROR_LECTURE_HAS_NO_PASSWORD = ApiError("lecture_has_no_password", HTTP_400_BAD_REQUEST, + "The specified lecture has no password authentication") +ERROR_AUTHENTICATION_FAILED = ApiError("authentication_failed", HTTP_403_FORBIDDEN, + "Authentication failed") +ERROR_UNAUTHORIZED = ApiError("unauthorized", HTTP_401_UNAUTHORIZED, + "You are not authorized") +ERROR_INVALID_CSRF_TOKEN = ApiError("invalid_csrf_token", HTTP_403_FORBIDDEN, + "The csrf token is missing or invalid") +ERROR_ACCESS_FORBIDDEN = ApiError("access_forbidden", HTTP_403_FORBIDDEN, + "You do not have access to this resource") +ERROR_RATE_LIMITED = ApiError("rate_limited", HTTP_429_TOO_MANY_REQUESTS, + "You are sending too many requests and are being rate limited. Try again later") +ERROR_UNKNOWN_OBJECT = ApiError("unknown_object", HTTP_404_NOT_FOUND, + "Cannot find the specified object") +ERROR_MODIFICATION_UNEXPECTED_CURRENT_VALUE = ApiError("modification_unexpected_current_value", HTTP_409_CONFLICT, + "The current value does not match the expected last known value") +ERROR_TOO_MANY_SUGGESTIONS = ApiError("too_many_suggestions", HTTP_403_FORBIDDEN, + "Due to too many suggestion, no more are accepted") +ERROR_SITE_IS_READONLY = ApiError("site_is_readonly", HTTP_503_SERVICE_UNAVAILABLE, + "The site is currently in read-only mode") +ERROR_SITE_IS_DISABLED = ApiError("site_is_disabled", HTTP_503_SERVICE_UNAVAILABLE, + "The site is currently disabled") +ERROR_SITE_IS_OVERLOADED = ApiError("site_is_overloaded", HTTP_503_SERVICE_UNAVAILABLE, + "Your request failed, as the site is currently experiencing a lot of traffic") + +ALL_ERRORS_RANDOM = [ + ERROR_BAD_REQUEST(), + ERROR_METHOD_NOT_ALLOWED, + ERROR_MALFORMED_JSON, + ERROR_REQUEST_MISSING_PARAMETER("?"), + ERROR_REQUEST_INVALID_PARAMETER("?", "?"), + ERROR_UNKNOWN_REQUEST_PATH, + ERROR_INTERNAL_SERVER_ERROR, + ERROR_LECTURE_HAS_NO_PASSWORD, + ERROR_AUTHENTICATION_FAILED, + ERROR_AUTHENTICATION_NOT_AVAILABLE(), + ERROR_UNAUTHORIZED, + ERROR_INVALID_CSRF_TOKEN, + ERROR_ACCESS_FORBIDDEN, + ERROR_RATE_LIMITED, + ERROR_UNKNOWN_OBJECT, + ERROR_OBJECT_ERROR("?"), + ERROR_TOO_MANY_SUGGESTIONS, + ERROR_SITE_IS_READONLY, + ERROR_SITE_IS_DISABLED, + ERROR_SITE_IS_OVERLOADED +] diff --git a/src/videoag_common/miscellaneous/json.py b/src/videoag_common/miscellaneous/json.py new file mode 100644 index 0000000000000000000000000000000000000000..04da1bc75f8623c30fe3dd0736c750ab1dd0e513 --- /dev/null +++ b/src/videoag_common/miscellaneous/json.py @@ -0,0 +1,163 @@ +from typing import Iterable + +from videoag_common.miscellaneous.util import JsonTypes + +from .constants import * +from .errors import ( + ApiClientException, + ERROR_REQUEST_INVALID_PARAMETER, + ERROR_REQUEST_MISSING_PARAMETER +) + + +class CJsonValue: + + def __init__(self, data, path: str = "$"): + self._data = data + self._path = path + + def raise_error(self, message: str): + raise ApiClientException(ERROR_REQUEST_INVALID_PARAMETER(self._path, message)) + + def equals_json(self, json: JsonTypes): + return self._data == json + + def is_null(self): + return self._data is None + + def as_object(self) -> "CJsonObject": + self.__check_value_type(dict, "object") + return CJsonObject(self._data, self._path) + + def as_array(self) -> "CJsonArray": + self.__check_value_type(list, "array") + return CJsonArray(self._data, self._path) + + def as_bool(self) -> bool: + self.__check_value_type(bool, "boolean") + return self._data + + def as_sint32(self) -> int: + return self.as_int(MIN_VALUE_SINT32, MAX_VALUE_SINT32) + + def as_int(self, min_value: int, max_value: int) -> int: + self.__check_value_type(int, "int") + if self._data < min_value: + self.raise_error(f"Value must not be smaller than {min_value}") + if self._data > max_value: + self.raise_error(f"Value must not be greater than {max_value}") + return self._data + + def as_string(self, max_length: int, min_length: int = None) -> str: + self.__check_value_type(str, "string") + if min_length is not None and len(self._data) < min_length: + self.raise_error(f"String must not be shorter than {min_length}") + if len(self._data) > max_length: + self.raise_error(f"String must not be longer than {max_length}") + return self._data + + def __check_value_type(self, expected_type: type, type_name: str): + if self._data is None or not isinstance(self._data, expected_type): + self.raise_error(f"Expected {type_name}") + return self._data + + +class CJsonObject(CJsonValue): + + def __init__(self, data: {}, path: str = "$"): + super().__init__(data, path) + + def has(self, key: str): + return key in self._data + + def keys(self): + return self._data.keys() + + def raise_if_present(self, key: str): + if self.has(key): + self.get(key).raise_error("This value should not be present") + + def get(self, key: str, optional: bool = False): + if optional and not self.has(key): + return None + if key not in self._data: + raise ApiClientException(ERROR_REQUEST_MISSING_PARAMETER(key)) + return CJsonValue(self._data[key], f"{self._path}.{key}") + + def get_object(self, key: str) -> "CJsonObject": + return self.get(key).as_object() + + def get_array(self, key: str) -> "CJsonArray": + return self.get(key).as_array() + + def get_bool(self, key: str, default: bool or None = None) -> bool: + if default is not None and not self.has(key): + return default + return self.get(key).as_bool() + + def get_sint32(self, key: str, default: int or None = None) -> int: + if default is not None and not self.has(key): + return default + return self.get(key).as_sint32() + + def get_int(self, key: str, min_value: int, max_value: int, optional: bool = False) -> int or None: + if optional and not self.has(key): + return None + return self.get(key).as_int(min_value, max_value) + + def get_string(self, key: str, max_length: int, min_length: int = None, optional: bool = False) -> str or None: + if optional and not self.has(key): + return None + return self.get(key).as_string(max_length, min_length) + + +class _ArrayIterator: + + def __init__(self, array: "CJsonArray"): + super().__init__() + self._array = array + self._i = 0 + + def __next__(self): + if self._i >= self._array.length(): + raise StopIteration + ele = self._array.get(self._i) + self._i += 1 + return ele + + +class CJsonArray(CJsonValue, Iterable[CJsonValue]): + + def __init__(self, data: [], path: str = "$"): + super().__init__(data, path) + + def length(self) -> int: + return len(self._data) + + def get(self, index: int): + if index < 0: + raise ValueError(f"Negative index provided: {index}") # pragma: no cover + if index >= self.length(): + raise ApiClientException(ERROR_REQUEST_MISSING_PARAMETER(f"{self._path}[{index}]")) + return CJsonValue(self._data[index], f"{self._path}[{index}]") + + def get_object(self, index: int) -> "CJsonObject": + return self.get(index).as_object() + + def get_array(self, index: int) -> "CJsonArray": + return self.get(index).as_array() + + def get_bool(self, index: int) -> bool: + return self.get(index).as_bool() + + def get_sint32(self, index: int) -> int: + return self.get(index).as_sint32() + + def get_int(self, index: int, min_value: int, max_value: int) -> int: + return self.get(index).as_int(min_value, max_value) + + def get_string(self, index: int, max_length: int, min_length: int = None) -> str: + return self.get(index).as_string(max_length, min_length) + + def __iter__(self): + return _ArrayIterator(self) diff --git a/src/videoag_common/miscellaneous/util.py b/src/videoag_common/miscellaneous/util.py new file mode 100644 index 0000000000000000000000000000000000000000..9e2632cfe061231f6ece2d531ce280893a3accbb --- /dev/null +++ b/src/videoag_common/miscellaneous/util.py @@ -0,0 +1,88 @@ +from typing import Callable, TypeVar, Iterable +import re + + +JsonTypes = dict[str, "JsonTypes"] or list["JsonTypes"] or str or int or bool or None + + +ID_STRING_REGEX_NO_LENGTH = "(?=.*[a-z_-])[a-z0-9_-]*" +ID_STRING_PATTERN_NO_LENGTH = re.compile(ID_STRING_REGEX_NO_LENGTH) + + +DEFAULT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" + + +class ArgAttributeObject: + def __init__(self, **kwargs): + super().__init__() + for name, value in kwargs.items(): + setattr(self, name, value) + + +def _raise(exception: Exception): + raise exception + + +def truncate_string(value: str, limit: int = 50) -> str: + if len(value) <= limit: + return value + if limit < 5: + raise ValueError("Truncate limit too small") # pragma: no cover + first = (limit-3)//2 + second = (limit-3) - first + return value[:first] + "..." + value[(len(value)-second):len(value)] + + +_T = TypeVar("_T") +_R = TypeVar("_R") + + +def flat_map(func: Callable[[_T], Iterable[_R]], it: Iterable[_T]) -> Iterable[_R]: + for e in it: + for sub_e in func(e): + yield sub_e + + +def recursive_flat_map(extractor: Callable[[_T], Iterable[_T]], it: Iterable[_T]) -> Iterable[_T]: + for e in it: + yield e + for sub_e in recursive_flat_map(extractor, extractor(e)): + yield sub_e + + +def recursive_flat_map_single(extractor: Callable[[_T], Iterable[_T]], e: _T) -> Iterable[_T]: + yield e + for sub_e in recursive_flat_map(extractor, extractor(e)): + yield sub_e + + +def dict_get_check_type(dict: dict, key, type: type, default=None): + if key not in dict: + if default is not None: + return default + raise ValueError(f"Missing value of type '{type}' for key '{key}'") + val = dict[key] + if not isinstance(val, type): + raise TypeError(f"Value for key '{key}' is '{type(val)}' but expected '{type}'") + return val + + +def alphanum_camel_case_to_snake_case(val: str): + if not val.isascii(): + raise ValueError(f"Value '{val}' contains non ascii character") + res = "" + prev_upper = False + for i in range(0, len(val)): + char = val[i] + if char.islower() or char.isnumeric(): + res += char + prev_upper = False + continue + if not char.isupper(): + raise ValueError(f"Value '{val}' contains invalid character '{char}'") + next_upper = i+1 < len(val) and val[i+1].isupper() + if i > 0 and (not prev_upper or not next_upper): + res += "_" + res += char.lower() + prev_upper = True + return res diff --git a/src/videoag_common/objects/__init__.py b/src/videoag_common/objects/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..87a9d04cc057d94da53282325e78c6683c42fe1d --- /dev/null +++ b/src/videoag_common/objects/__init__.py @@ -0,0 +1,46 @@ +from .course import Course, Lecture, Chapter +from .site import Announcement, AnnouncementType, AnnouncementPageVisibility, Featured +from .user import User +from .medium import PublishMedium +from .view_permissions import ViewPermissions, ViewPermissionsType, EffectiveViewPermissions +from .changelog import ( + ChangelogEntry, + ChangelogEntryType, + ChangelogCreationEntry, + ChangelogModificationEntry, + ChangelogDeletionChangeEntry, + ChangelogUnknownEntry +) + +from ..api_object import ApiObjectClass, ApiObject + + +def __init_all_classes() -> dict[str, ApiObjectClass]: + from ..database import Base + Base.registry.configure() + + all_classes: dict[str, ApiObjectClass] = {} + for clazz in Base.__subclasses__(): + if not issubclass(clazz, ApiObject): + _check_no_api_object_subclass(clazz) + continue + + if hasattr(clazz, "__api_class__"): + api_class = clazz.__api_class__ + else: + api_class = ApiObjectClass() + setattr(clazz, "__api_class__", api_class) + + api_class.set_class(clazz) + if api_class.id in all_classes: + raise ValueError(f"Duplicate API object with id '{api_class.id}'") + all_classes[api_class.id] = api_class + + for object in all_classes.values(): + # noinspection PyProtectedMember + object._post_init(all_classes) + + return all_classes + + +API_CLASSES_BY_ID = __init_all_classes() diff --git a/src/videoag_common/objects/changelog.py b/src/videoag_common/objects/changelog.py new file mode 100644 index 0000000000000000000000000000000000000000..e32fce30ad93d46e235a2e66f2aadeb71df0d247 --- /dev/null +++ b/src/videoag_common/objects/changelog.py @@ -0,0 +1,159 @@ +from datetime import datetime +from enum import Enum + +from videoag_common.database import * +from videoag_common.api_object import * +from .user import User + + +class ChangelogEntryType(Enum): + MODIFICATION = "modification" + DELETION_CHANGE = "deletion_change" + CREATION = "creation" + UNKNOWN = "unknown" + + +_CHANGELOG_ENTRY_TYPE_ENUM = create_enum_type(ChangelogEntryType) + + +class ChangelogEntry(ApiObject, Base): + __mapper_args__ = { + "polymorphic_on": "type" + } + + change_time: Mapped[datetime] = api_mapped( + mapped_column(TIMESTAMP(), nullable=False, server_default=sql.text("CURRENT_TIMESTAMP")), + ApiDatetimeField( + include_in_data=True + ) + ) + modifying_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False) + + type: Mapped[ChangelogEntryType] = api_mapped( + mapped_column(_CHANGELOG_ENTRY_TYPE_ENUM, nullable=False), + ApiEnumField( + include_in_data=True + ) + ) + + object_type: Mapped[str] = api_mapped( + mapped_column(String(collation=STRING_COLLATION), nullable=False), + ApiStringField( + include_in_data=True, data_notes="Note that this object type may not exist anymore" + ) + ) + object_id: Mapped[int] = api_mapped( + mapped_column(nullable=False), + ApiIntegerField( + include_in_data=True + ) + ) + + modifying_user: Mapped[User] = api_mapped( + relationship( + primaryjoin=lambda: User.id == ChangelogEntry.modifying_user_id, + lazy="raise_on_sql" + ), + ApiMany2OneRelationshipField( + include_in_data=True, data_foreign_in_context=True + ) + ) + + @classmethod + def select(cls, + is_mod: bool, + load_user: bool = False, + **kwargs): + return super().select(is_mod, **kwargs).options(*cls.load_options(load_user=load_user)) + + @staticmethod + def load_options( + load_user=False, + ) -> list[ExecutableOption]: + options = [] + + if load_user: + options.append( + orm.selectinload(ChangelogEntry.modifying_user).options() + ) + return options + + +class ChangelogModificationEntry(ChangelogEntry): + __tablename__ = None # Prevent our own base from adding a table name. This should be a single-table inheritance + __mapper_args__ = { + "polymorphic_identity": ChangelogEntryType.MODIFICATION + } + + field_id: Mapped[str] = api_mapped( + mapped_column(String(collation=STRING_COLLATION), nullable=True), + ApiStringField( + include_in_data=True, data_notes="Note that this field may not exist anymore" + ) + ) + old_value: Mapped[dict] = api_mapped( + mapped_column(sql.types.JSON(), nullable=True), + ApiJsonField( + include_in_data=True, data_notes="Note that this may contain arbitrary json due to legacy values" + ) + ) + new_value: Mapped[dict] = api_mapped( + mapped_column(sql.types.JSON(), nullable=True), + ApiJsonField( + include_in_data=True, data_notes="Note that this may contain arbitrary json due to legacy values" + ) + ) + + +class ChangelogDeletionChangeEntry(ChangelogEntry): + __tablename__ = None + __mapper_args__ = { + "polymorphic_identity": ChangelogEntryType.DELETION_CHANGE + } + + is_now_deleted: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=False), + ApiBooleanField( + include_in_data=True + ) + ) + + +class ChangelogCreationEntry(ChangelogEntry): + __tablename__ = None + __mapper_args__ = { + "polymorphic_identity": ChangelogEntryType.CREATION + } + + parent_type: Mapped[str] = api_mapped( + mapped_column(String(collation=STRING_COLLATION), nullable=True), + ApiStringField( + include_in_data=True + ) + ) + parent_id: Mapped[int] = api_mapped( + mapped_column(nullable=True), + ApiIntegerField( + include_in_data=True + ) + ) + variant: Mapped[str] = api_mapped( + mapped_column(String(collation=STRING_COLLATION), nullable=True), + ApiStringField( + include_in_data=True + ) + ) + + +class ChangelogUnknownEntry(ChangelogEntry): + __tablename__ = None + __mapper_args__ = { + "polymorphic_identity": ChangelogEntryType.UNKNOWN + } + + data: Mapped[dict] = api_mapped( + mapped_column(sql.types.JSON, nullable=True), + ApiJsonField( + include_in_data=True + ) + ) diff --git a/src/videoag_common/objects/course.py b/src/videoag_common/objects/course.py new file mode 100644 index 0000000000000000000000000000000000000000..032d8710dcb67ac2e86d5423fb46c82f0c9767b4 --- /dev/null +++ b/src/videoag_common/objects/course.py @@ -0,0 +1,487 @@ +from datetime import datetime + +from videoag_common.database import * +from videoag_common.api_object import * +from .view_permissions import EffectiveViewPermissions, ApiViewPermissionsObject, DEFAULT_VIEW_PERMISSIONS + + +class Chapter(DeletableApiObject, VisibilityApiObject, Base): + __api_class__ = ApiObjectClass( + parent_relationship_config_ids=["lecture"] + ) + + lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=False) + # TODO check duration + start_time: Mapped[int] = api_mapped( + mapped_column(nullable=False), + ApiIntegerField( + min_value=0, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, data_notes="In seconds, >= 0" + ) + ) + name: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False), + ApiStringField( + max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + + lecture: Mapped["Lecture"] = relationship( + primaryjoin=lambda: Lecture.id == Chapter.lecture_id, + back_populates="chapters", + lazy="raise_on_sql" + ) + + +responsible_table = sql.Table( + "course_responsible", + Base.metadata, + sql.Column("course_id", ForeignKey("course.id"), primary_key=True), + sql.Column("user_id", ForeignKey("user.id"), primary_key=True), +) + + +class Lecture(DeletableApiObject, VisibilityApiObject, ApiViewPermissionsObject, Base): + __api_class__ = ApiObjectClass( + parent_relationship_config_ids=["course"] + ) + + course_id: Mapped[int] = mapped_column(ForeignKey("course.id"), nullable=False) + title: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + min_length=1, max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + speaker: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, + data_notes="Name of the speaker" + ) + ) + location: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + time: Mapped[datetime] = api_mapped( + mapped_column(TIMESTAMP(), nullable=False), + ApiDatetimeField( + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + duration: Mapped[int] = api_mapped( + mapped_column(nullable=False, default=90), + ApiIntegerField( + min_value=0, max_value=7 * 24 * 60, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, + data_notes="In minutes" + ) + ) + description: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=8192, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + # TODO thumbnail url + no_recording: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=False), + ApiBooleanField( + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + livestream_planned: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=False), + ApiBooleanField( + include_in_config=True, + include_in_data=True + ) + ) + internal_comment: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=8192, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, data_only_mod=True + ) + ) + publish_time: Mapped[datetime] = api_mapped( + mapped_column(TIMESTAMP(), nullable=True), + ApiDatetimeField( + include_in_data=True + ) + ) + + course: Mapped["Course"] = api_mapped( + relationship( + primaryjoin=lambda: Course.id == Lecture.course_id, + back_populates="lectures", + lazy="raise_on_sql" + ), + ApiMany2OneRelationshipField( + include_in_data=True, data_foreign_in_context=True + ) + ) + chapters: Mapped[list[Chapter]] = relationship( + back_populates="lecture", + primaryjoin=lambda: sql.and_(Chapter.lecture_id == Lecture.id, Chapter.has_access(is_mod=True)), + lazy="raise_on_sql" + ) + + @staticmethod + def __lambda_primary_join_media(): + from .medium import PublishMedium + return sql.and_( + PublishMedium.lecture_id == Lecture.id, + PublishMedium.has_access(is_mod=True) + ) + + # noinspection PyUnresolvedReferences + publish_media: Mapped[list["PublishMedium"]] = relationship( + back_populates="lecture", + primaryjoin=__lambda_primary_join_media, + lazy="raise_on_sql" + ) + public_chapters: Mapped[list[Chapter]] = relationship( + back_populates="lecture", + primaryjoin=lambda: sql.and_(Chapter.lecture_id == Lecture.id, Chapter.has_access(is_mod=False)), + lazy="raise_on_sql", + viewonly=True + ) + + @staticmethod + def __lambda_primary_join_public_media(): + from .medium import PublishMedium + return sql.and_( + PublishMedium.lecture_id == Lecture.id, + PublishMedium.has_access(is_mod=False) + ) + + # noinspection PyUnresolvedReferences + public_publish_media: Mapped[list["PublishMedium"]] = relationship( + back_populates="lecture", + primaryjoin=__lambda_primary_join_public_media, + lazy="raise_on_sql", + viewonly=True + ) + + @property + def effective_view_permissions(self) -> EffectiveViewPermissions: + return self.view_permissions.get_effective(self.course.effective_view_permissions) + + @api_include_in_data( + type_id="chapter[]", + data_id="chapters", + data_if=lambda lec, args: args.include_chapters, + data_notes="Only present if requested. Sorted according to the start_time (ascending order)" + ) + def _data_get_chapters(self, is_mod: bool): + return self.chapters if is_mod else self.public_chapters + + @api_include_in_data( + type_id="publish_medium[]", + data_id="publish_media", + data_if=lambda lec, args: args.include_media, + data_notes="Only present if requested. All (public) media. May be empty" + ) + def _data_get_publish_media(self, is_mod: bool): + return self.publish_media if is_mod else self.public_publish_media + + @api_include_in_data( + type_id="string[]", + data_notes="possible values for string: `public` (Everyone has access, array will contain no other values), " + "`rwth`, `moodle`, `fsmpi`, `password`; Empty array indicates no access" + ) + def authentication_methods(self): + return self.effective_view_permissions.get_authentication_methods() + + @hybrid_method + def is_active(self): + return super().is_active() & self.hybrid_rel(self.course, "is_active") + + @hybrid_method + def has_access(self, + is_mod: bool, + visibility_check: bool = True, + **kwargs): + return ( + super().has_access(is_mod, visibility_check=visibility_check, **kwargs) + & self.hybrid_rel(self.course, "has_access", is_mod, visibility_check=visibility_check, **kwargs) + ) + + @classmethod + def select(cls, + is_mod: bool, + visibility_check: bool = True, + load_course=False, + load_chapters=False, + load_media=False, + **kwargs): + return ( + super().select(is_mod, visibility_check=visibility_check, **kwargs) + .options(*Lecture.load_options( + is_mod, + False, + load_course=load_course, + load_chapters=load_chapters, + load_media=load_media + )) + ) + + @staticmethod + def load_options( + is_mod: bool, + from_course: bool, + load_course=False, + load_chapters=False, + load_media=False + ) -> list[ExecutableOption]: + """ + + :param is_mod: If false, the public_publish_media, etc. are loaded + :param from_course: Set to True if these options are applied to an orm.selectinload(Course.lectures) (or similar) + :param load_course: Whether to load the course + :param load_chapters: Whether to load the chapters + :param load_media: Whether to load the publish_media + :return: The options + """ + options = [] + + if load_chapters: + options.append( + orm.selectinload(Lecture.chapters if is_mod else Lecture.public_chapters).options( + orm.immediateload(Chapter.lecture) + ) + ) + + if load_media: + from .medium import PublishMedium + options.append( + orm.selectinload(Lecture.publish_media if is_mod else Lecture.public_publish_media).options( + orm.immediateload(PublishMedium.lecture) + ) + ) + + if from_course: + # Causes no extra sql, just that the back reference is set + options.append(orm.immediateload(Lecture.course)) + elif load_course: + options.append(orm.joinedload(Lecture.course)) + return options + + +FORBIDDEN_COURSE_ID_STRING_VALUES = ["courses", "faq", "imprint", "licenses", "search", "site", "internal"] + + +class Course(DeletableApiObject, VisibilityApiObject, ApiViewPermissionsObject, Base): + + handle: Mapped[str] = api_mapped( + mapped_column(String(32, STRING_COLLATION), nullable=False), + ApiStringField( + min_length=1, max_length=32, unique=True, + regex=f"(?!(?:{'|'.join(FORBIDDEN_COURSE_ID_STRING_VALUES)})$)[a-zA-Z0-9_-]+", + include_in_config=True, + include_in_data=True, + data_notes="Unique" + ) + ) + full_name: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + min_length=1, max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + short_name: Mapped[str] = api_mapped( + mapped_column(String(32, STRING_COLLATION), nullable=False, default=""), + ApiStringField( + min_length=1, max_length=32, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + semester: Mapped[str] = api_mapped( + mapped_column(String(6, STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=6, regex="([0-9]{4}(ws|ss)|none)", + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + organizer: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + topic: Mapped[str] = api_mapped( + mapped_column(String(32, STRING_COLLATION), nullable=False, default=""), + ApiStringField( + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + description: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=8192, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + show_chapters_on_course: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=False), + ApiBooleanField( + include_in_config=True, + include_in_data=True, + data_notes="If true, chapters should be shown on the course page" + ) + ) + authentication_information: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=True, default=None), + ApiStringField( + max_length=8192, + include_in_config=True, + include_in_data=True, + data_notes="May contain additional information on how the user can authenticate themselves" + ) + ) + listed: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=True), + ApiBooleanField( + include_in_config=True, + include_in_data=True, data_only_mod=True, + data_notes="If false, course can only be visited with its url (Unless user is moderator)" + ) + ) + internal_comment: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=8192, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, data_only_mod=True + ) + ) + + allow_download: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=True), + ApiBooleanField( + include_in_config=True + ) + ) + allow_embed: Mapped[bool] = api_mapped( + mapped_column(nullable=False, default=False), + ApiBooleanField( + include_in_config=True, + include_in_data=True + ) + ) + + # noinspection PyUnresolvedReferences + responsible_users: Mapped[list["User"]] = api_mapped( + relationship( + secondary=responsible_table, + back_populates="responsible_courses" + ), + ApiAny2ManyRelationshipField( + include_in_config=True + ) + ) + lectures: Mapped[list[Lecture]] = relationship( + back_populates="course", + primaryjoin=lambda: sql.and_(Lecture.course_id == Course.id, ~Lecture.deleted), + lazy="raise_on_sql" + ) + public_lectures: Mapped[list[Lecture]] = relationship( + back_populates="course", + primaryjoin=lambda: sql.and_(Lecture.course_id == Course.id, ~Lecture.deleted, Lecture.visible), + lazy="raise_on_sql", + viewonly=True + ) + + @property + def effective_view_permissions(self): + return self.view_permissions.get_effective(DEFAULT_VIEW_PERMISSIONS) + + @api_include_in_data( + type_id="string[]", + data_notes="Default authentication methods for lectures in this course. Some Lectures might have different " + "methods (A lecture object always contains its authentication methods). See lecture for possible values" + ) + def default_authentication_methods(self): + return self.effective_view_permissions.get_authentication_methods() + + @api_include_in_data( + type_id="lecture[]", + data_id="lectures", + data_if=lambda course, args: args.include_lectures, + data_notes="Only present if requested. All (public) lectures. Lectures will include chapters and media_sources" + ) + def _data_lectures(self, is_mod: bool): + return self.lectures if is_mod else self.public_lectures + + @hybrid_method + def has_access(self, + is_mod: bool, + visibility_check: bool = True, + ignore_unlisted: bool = False, + **kwargs): + cond = super().has_access(is_mod, visibility_check, **kwargs) + if not is_mod and visibility_check and not ignore_unlisted: + cond &= self.listed + return cond + + @classmethod + def select(cls, + is_mod: bool, + visibility_check: bool = True, + ignore_unlisted: bool = False, + load_lectures=False, + load_chapters=False, + load_media=False, + **kwargs): + return ( + super().select(is_mod, visibility_check=visibility_check, ignore_unlisted=ignore_unlisted, **kwargs) + .options(*Course.load_options( + is_mod, + load_lectures=load_lectures, + load_chapters=load_chapters, + load_media=load_media + )) + ) + + @staticmethod + def load_options( + is_mod: bool, + load_lectures=False, + load_chapters=False, + load_media=False) -> list[ExecutableOption]: + options = [] + + if load_lectures: + options.append( + orm.selectinload(Course.lectures if is_mod else Course.public_lectures).options( + *Lecture.load_options(is_mod, True, load_chapters=load_chapters, load_media=load_media) + ) + ) + return options diff --git a/src/videoag_common/objects/medium.py b/src/videoag_common/objects/medium.py new file mode 100644 index 0000000000000000000000000000000000000000..bab07c8ab5c952ee8e3fbd4c8adf1b3733944cd2 --- /dev/null +++ b/src/videoag_common/objects/medium.py @@ -0,0 +1,27 @@ + +from videoag_common.database import * +from videoag_common.api_object import * + +from .course import Lecture + + +class PublishMedium(VisibilityApiObject, DeletableApiObject, Base): + __api_data__ = ApiObjectClass( + parent_relationship_config_ids=["lecture"] + ) + + lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=False) + title: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=256, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + + lecture: Mapped["Lecture"] = relationship( + primaryjoin=lambda: Lecture.id == PublishMedium.lecture_id, + back_populates="publish_media", + lazy="raise_on_sql" + ) diff --git a/src/videoag_common/objects/site.py b/src/videoag_common/objects/site.py new file mode 100644 index 0000000000000000000000000000000000000000..bbe4919dc000ed8d9862f955c94cfeeb9c94938c --- /dev/null +++ b/src/videoag_common/objects/site.py @@ -0,0 +1,224 @@ +from datetime import datetime +from enum import Enum + +from videoag_common.database import * +from videoag_common.api_object import * +from .course import Course, Lecture + + +class AnnouncementType(Enum): + INFO = "info" + WARNING = "warning" + IMPORTANT = "important" + + +class AnnouncementPageVisibility(Enum): + ONLY_MAIN_PAGE = "only_main_page" + ALL_PAGES = "all_pages" + ALL_PAGES_AND_EMBED = "all_pages_and_embed" + + +ANNOUNCEMENT_TYPE_ENUM = create_enum_type(AnnouncementType) +ANNOUNCEMENT_PAGE_VISIBILITY_ENUM = create_enum_type(AnnouncementPageVisibility) + + +class Announcement(DeletableApiObject, VisibilityApiObject, Base): + + type: Mapped[AnnouncementType] = api_mapped( + mapped_column(ANNOUNCEMENT_TYPE_ENUM, nullable=False), + ApiEnumField( + include_in_config=True, + include_in_data=True + ) + ) + page_visibility: Mapped[AnnouncementPageVisibility] = api_mapped( + mapped_column(ANNOUNCEMENT_PAGE_VISIBILITY_ENUM, nullable=False), + ApiEnumField( + include_in_config=True, + include_in_data=True + ) + ) + text: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=8192, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + publish_time: Mapped[datetime] = api_mapped( + mapped_column(TIMESTAMP(), nullable=True), + ApiDatetimeField( + include_in_config=True, + include_in_data=True, data_only_mod=True + ) + ) + expiration_time: Mapped[datetime] = api_mapped( + mapped_column(TIMESTAMP(), nullable=True), + ApiDatetimeField( + include_in_config=True, + include_in_data=True, data_only_mod=True + ) + ) + + +class FeaturedType(Enum): + PLAIN = "plain" + IMAGE = "image" + COURSE = "course" + LECTURE = "lecture" + + +FEATURED_TYPE_ENUM = create_enum_type(FeaturedType) + + +class Featured(DeletableApiObject, VisibilityApiObject, Base): + __mapper_args__ = { + "polymorphic_on": "type", + "polymorphic_identity": FeaturedType.PLAIN + } + title: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=1024, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + text: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""), + ApiStringField( + max_length=8192, + include_in_config=True, config_directly_modifiable=True, + include_in_data=True + ) + ) + type: Mapped[FeaturedType] = api_mapped( + mapped_column(FEATURED_TYPE_ENUM, nullable=False, default=FeaturedType.PLAIN), + ApiEnumField( + include_in_config=True, config_only_at_creation=True, + include_in_data=True, data_notes="Specifies the variant" + ) + ) + display_priority: Mapped[int] = api_mapped( + mapped_column(nullable=False, default=0), + ApiIntegerField( # TODO custom field? unique? + include_in_config=True, config_directly_modifiable=True, + include_in_data=True, data_only_mod=True, + data_notes="Smallest value is at top. If the order is the same, the lower id is higher" + ) + ) + + @classmethod + def select(cls, + is_mod: bool, + load_course_lecture=False, + **kwargs): + return ( + super().select(is_mod, **kwargs) + .options(*cls.load_options(is_mod, load_course_lecture=load_course_lecture)) + ) + + @staticmethod + def load_options( + is_mod: bool, + load_course_lecture=False, + ) -> list[ExecutableOption]: + options = [] + + if load_course_lecture: + options.extend( + [ + orm.selectinload(LectureFeatured.lecture).options( + *Lecture.load_options( + is_mod, + False, + load_course=True, + load_chapters=True, + load_media=True + ) + ), + orm.selectinload(CourseFeatured.course).options( + *Course.load_options( + is_mod, + False + ) + ) + ] + ) + return options + + +class ImageFeatured(Featured): + __tablename__ = None # Prevent our own base from adding a table name. This should be a single-table inheritance + __mapper_args__ = { + "polymorphic_identity": FeaturedType.IMAGE + } + image_url: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=True, default=""), # Nullable because of single-table inheritance + ApiStringField( + max_length=8192, + include_in_config=True, + include_in_data=True + ) + ) + + +class CourseFeatured(Featured): + __tablename__ = None # Prevent our own base from adding a table name. This should be a single-table inheritance + __mapper_args__ = { + "polymorphic_identity": FeaturedType.COURSE + } + course_id: Mapped[int] = mapped_column(ForeignKey("course.id"), nullable=True) # Nullable because of single-table inheritance + + course: Mapped[Course] = api_mapped( + relationship( + primaryjoin=lambda: sql.and_(Course.id == CourseFeatured.course_id, Course.has_access(is_mod=True)), + lazy="raise_on_sql" + ), + ApiMany2OneRelationshipField( + may_be_none=False, + include_in_config=True + ) + ) + public_course: Mapped[Course] = api_mapped( + relationship( + primaryjoin=lambda: sql.and_(Course.id == CourseFeatured.course_id, Course.has_access(is_mod=False, ignore_unlisted=True)), + lazy="raise_on_sql", + viewonly=True + ), + ApiMany2OneRelationshipField( + include_in_data=True, data_foreign_in_context=True, data_id="course_id", + ) + ) + + +class LectureFeatured(Featured): + __tablename__ = None # Prevent our own base from adding a table name. This should be a single-table inheritance + __mapper_args__ = { + "polymorphic_identity": FeaturedType.LECTURE + } + lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=True) # Nullable because of single-table inheritance + + lecture: Mapped[Lecture] = api_mapped( + relationship( + primaryjoin=lambda: sql.and_(orm.foreign(Lecture.id) == LectureFeatured.lecture_id, Lecture.has_access(is_mod=True)), + lazy="raise_on_sql" + ), + ApiMany2OneRelationshipField( + may_be_none=False, + include_in_config=True + ) + ) + # TODO what happens if not visible? + public_lecture: Mapped[Lecture] = api_mapped( + relationship( + primaryjoin=lambda: sql.and_(orm.foreign(Lecture.id) == LectureFeatured.lecture_id, Lecture.has_access(is_mod=False)), + lazy="raise_on_sql", + viewonly=True + ), + ApiMany2OneRelationshipField( + include_in_data=True, data_id="lecture", data_notes="May be null (deleted/not visible). Does not include " + "chapters and media_sources" + ) + ) diff --git a/src/videoag_common/objects/user.py b/src/videoag_common/objects/user.py new file mode 100644 index 0000000000000000000000000000000000000000..4b76032c91b5e42944e45547f78156a7f66cba55 --- /dev/null +++ b/src/videoag_common/objects/user.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from videoag_common.database import * +from videoag_common.api_object import * +from .course import Course, responsible_table + + +class User(ApiObject, Base): + + handle: Mapped[str] = api_mapped( + mapped_column(String(32, STRING_COLLATION), nullable=False), + ApiStringField( + include_in_data=True, data_only_mod=True + ) + ) + name: Mapped[str] = api_mapped( + mapped_column(Text(collation=STRING_COLLATION), nullable=False), + ApiStringField( + include_in_data=True, data_only_mod=True + ) + ) + + last_login: Mapped[datetime] = mapped_column(TIMESTAMP(), nullable=True) + + responsible_courses: Mapped[list[Course]] = relationship( + secondary=responsible_table, + back_populates="responsible_users", + lazy="raise_on_sql" + ) diff --git a/src/videoag_common/objects/view_permissions.py b/src/videoag_common/objects/view_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..4d3a9681b09321933721dcfc233f5ec4c0a41601 --- /dev/null +++ b/src/videoag_common/objects/view_permissions.py @@ -0,0 +1,231 @@ +from enum import StrEnum +from typing import TypeVar, Generic + +from videoag_common.miscellaneous import * +from videoag_common.database import * +from videoag_common.api_object import * + + +class ViewPermissionsType(StrEnum): + PUBLIC = "public" + PRIVATE = "private" + AUTHENTICATION = "authentication" + INHERIT = "inherit" + + +VIEW_PERMISSIONS_TYPE_ENUM = create_enum_type(ViewPermissionsType) + + +class ViewPermissions: + + def __init__(self, + type: ViewPermissionsType, + rwth_authentication: bool = None, + fsmpi_authentication: bool = None, + moodle_course_ids: list[int] or None = None, + passwords: dict[str, str] or None = None): + super().__init__() + self.type = type + self.rwth_authentication = rwth_authentication + self.fsmpi_authentication = fsmpi_authentication + self.moodle_course_ids = moodle_course_ids + self.passwords = passwords + + is_auth = type == ViewPermissionsType.AUTHENTICATION + if (rwth_authentication and not is_auth) \ + or (fsmpi_authentication and not is_auth) \ + or ((moodle_course_ids is not None) != is_auth) \ + or ((passwords is not None) != is_auth): + raise TypeError("Invalid view permissions") + if is_auth \ + and not rwth_authentication \ + and not fsmpi_authentication \ + and len(moodle_course_ids) == 0 \ + and len(passwords) == 0: + raise RuntimeError("At least one authentication method must be present for type AUTHENTICATION") + + def get_effective(self, parent_permissions: "EffectiveViewPermissions"): + return EffectiveViewPermissions( + self, + parent_permissions + ) + + def to_json(self) -> dict: + if self.type == ViewPermissionsType.AUTHENTICATION: + return { + "type": self.type.value, + "rwth_authentication": self.rwth_authentication, + "fsmpi_authentication": self.fsmpi_authentication, + "moodle_course_ids": self.moodle_course_ids, + "passwords": self.passwords + } + else: + return { + "type": self.type.value + } + + def __eq__(self, __value): + return ( + isinstance(__value, ViewPermissions) + and self.type == __value.type + and self.rwth_authentication == __value.rwth_authentication + and self.fsmpi_authentication == __value.fsmpi_authentication + and self.passwords == __value.passwords + and (True if self.type != ViewPermissionsType.AUTHENTICATION + else set(self.moodle_course_ids) == set(__value.moodle_course_ids)) + ) + + +class EffectiveViewPermissions(ViewPermissions): + + def __init__(self, + object_permissions: ViewPermissions, + inherited_permissions: ViewPermissions = ViewPermissions(ViewPermissionsType.PUBLIC)): + """ + :param object_permissions: The permissions set for the object + :param inherited_permissions: Only relevant if `permissions.type == INHERIT`. May not have type INHERIT + """ + effective = object_permissions + if object_permissions.type == ViewPermissionsType.INHERIT: + if inherited_permissions.type == ViewPermissionsType.INHERIT: + raise RuntimeError("No indirect inheritance allowed") + effective = inherited_permissions + super().__init__( + effective.type, + effective.rwth_authentication, + effective.fsmpi_authentication, + effective.moodle_course_ids, + effective.passwords + ) + self.object_permissions = object_permissions + + def get_authentication_methods(self) -> list[str]: + match self.type: + case ViewPermissionsType.PUBLIC: + return ["public"] + case ViewPermissionsType.PRIVATE: + return [] + case ViewPermissionsType.AUTHENTICATION: + auth_list_json: list[str] = [] + if self.rwth_authentication: + auth_list_json.append("rwth") + if self.fsmpi_authentication: + auth_list_json.append("fsmpi") + if len(self.moodle_course_ids) > 0: + auth_list_json.append("moodle") + if len(self.passwords) > 0: + auth_list_json.append("password") + return auth_list_json + case ViewPermissionsType.INHERIT: + raise ValueError("Got INHERIT permission from get_effective_view_permissions") + case _: + raise ValueError(f"Unknown type {self.type}") + + +DEFAULT_VIEW_PERMISSIONS = ViewPermissions(ViewPermissionsType.PUBLIC) + + +def view_permissions_from_json(json: CJsonValue or JsonTypes) -> "ViewPermissions": + is_client = isinstance(json, CJsonValue) + if not is_client: + json = CJsonValue(json) + json = json.as_object() + try: + type_str: str = json.get_string("type", 100) + try: + type = ViewPermissionsType(type_str) + except ValueError: + raise json.raise_error(f"Unknown type '{truncate_string(type_str)}'") + rwth_authentication = None + fsmpi_authentication = None + moodle_course_ids = None + passwords = None + if type == ViewPermissionsType.AUTHENTICATION: + rwth_authentication = json.get_bool("rwth_authentication") + fsmpi_authentication = json.get_bool("fsmpi_authentication") + moodle_course_ids = [v.as_sint32() for v in json.get_array("moodle_course_ids")] + passwords_json = json.get_object("passwords") + passwords = {k: passwords_json.get_string(k, 1024) for k in passwords_json.keys()} + + if not rwth_authentication \ + and not fsmpi_authentication \ + and len(moodle_course_ids) == 0 \ + and len(passwords) == 0: + raise ApiClientException(ERROR_BAD_REQUEST(f"For type {ViewPermissionsType.AUTHENTICATION.value} at" + f" least one authentication method must be present")) + else: + json.raise_if_present("rwth_authentication") + json.raise_if_present("fsmpi_authentication") + json.raise_if_present("moodle_course_ids") + json.raise_if_present("passwords") + return ViewPermissions( + type, + rwth_authentication, + fsmpi_authentication, + moodle_course_ids, + passwords + ) + except ApiClientException as e: + if is_client: + raise e + raise RuntimeError(f"Invalid json: {e.error.message}") + + +_O = TypeVar("_O", bound="ApiViewPermissionsObject") + + +class ApiViewPermissionsField(ApiConfigField[_O], Generic[_O]): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def config_get_type_id(self) -> str: + return "view_permissions" + + def post_init_check(self, context: FieldContext): + super().post_init_check(context) + + def config_get_value(self, object_db: _O) -> JsonTypes: + return object_db.view_permissions.to_json() + + @staticmethod + def _set_perms_to_object(object_db: _O, perms: ViewPermissions): + object_db.view_perm_type = perms.type + object_db.view_perm_rwth_auth = perms.rwth_authentication + object_db.view_perm_fsmpi_auth = perms.fsmpi_authentication + object_db.view_perm_extra_json = None + if perms.type == ViewPermissionsType.AUTHENTICATION: + object_db.view_perm_extra_json = { + "moodle_course_ids": perms.moodle_course_ids, + "passwords": perms.passwords + } + + def config_set_value(self, session: SessionDb, object_db: _O, new_client_value: CJsonValue): + ApiViewPermissionsField._set_perms_to_object(object_db, view_permissions_from_json(new_client_value)) + pass + + def config_get_default(self) -> JsonTypes: + return DEFAULT_VIEW_PERMISSIONS.to_json() + + +class ApiViewPermissionsObject(ApiObject): + __api_fields__ = [ + ApiViewPermissionsField( + include_in_config=True, config_id="view_permissions" + ) + ] + view_perm_type: Mapped[ViewPermissionsType] = mapped_column(VIEW_PERMISSIONS_TYPE_ENUM, nullable=False, default=ViewPermissionsType.INHERIT) + view_perm_rwth_auth: Mapped[bool] = mapped_column(nullable=False, default=False) + view_perm_fsmpi_auth: Mapped[bool] = mapped_column(nullable=False, default=False) + view_perm_extra_json: Mapped[dict] = mapped_column(sql.types.JSON(), nullable=True, default=None) + + @property + def view_permissions(self): + is_auth = self.view_perm_type == ViewPermissionsType.AUTHENTICATION + return ViewPermissions( + self.view_perm_type, + self.view_perm_rwth_auth, + self.view_perm_fsmpi_auth, + self.view_perm_extra_json["moodle_course_ids"] if is_auth else None, + self.view_perm_extra_json["passwords"] if is_auth else None, + ) diff --git a/src/videoag_common/test/__init__.py b/src/videoag_common/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/videoag_common/test/database_test.py b/src/videoag_common/test/database_test.py new file mode 100644 index 0000000000000000000000000000000000000000..ce9482969c1a93889acf88d32273238fd83dd167 --- /dev/null +++ b/src/videoag_common/test/database_test.py @@ -0,0 +1,193 @@ +import json +import os +from unittest import TestCase +from pathlib import Path + +import api + +from api.database import db_pool + + +# noinspection PyProtectedMember +def reset_database(): + data_script = Path(os.path.join(os.getcwd(), api.config['DB_DATA'])).read_text(encoding="UTF-8") + if api.config["DB_ENGINE"] == "postgres": + import re + # Postgres sequences get out of sync when inserting rows with id + data_script = re.sub( + "--\\s*\\$POSTGRES_FIX_SEQUENCE\\s*\\(\\s*([a-zA-Z0-9_-]+)\\s*,\\s*([a-zA-Z0-9_-]+)\\s*\\)", + """\ +SELECT setval(pg_get_serial_sequence('\\1', '\\2'), COALESCE((SELECT MAX("\\2")+1 FROM "\\1"), 1), false);\ +""", data_script) + + db_pool.execute_script(data_script, wrap_in_transaction=True) + + +class DatabaseTest(TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def setUp(self): + super().setUp() + reset_database() + + def tearDown(self): + super().tearDown() + + @staticmethod + def _get_index_of_object(list: list, object): + i: int = 0 + for entry in list: + if entry is object: + return i + i += 1 + raise ValueError("Object not found") + + @staticmethod + def _list_minus(first_list: list, second_list: list): + res = [] + for l1 in first_list: + for l2 in second_list: + if l1 == l2: + break + else: # Loop did not break + res.append(l1) + return res + + @staticmethod + def _json_check_is_ignored(ignored_keys: list[str] or None, path: str): + for ignore_key in ignored_keys: + if (ignore_key == path) if ignore_key.count(".") != 0 else (ignore_key == path.split(".")[-1]): + return True + return False + + def assertJsonEqual(self, + expected: object, + data: object, + ignore_array_order_keys: list[str] or None = None, + ignore_value_keys: list[str] or None = None, + path: str = "$", + only_print_errors: bool = False) -> int: + """ + Checks if two json values are equal. All differences are printed and then an exception is raised + (unless only_print_errors is True) + :param expected: Expected data. + :param data: Data to check + :param ignore_array_order_keys: A list of keys which refer to arrays for which the order of elements does not + matter. Format is either a full path or a single key. A full path are the object + keys separated by '.' and arrays with '[<index>]'. The beginning is a '$'. + Example: '$.values[0].type' refers to value 'type' in the first object of array + 'values'. + A single key is one without any '.' This matches all arrays which are saved in + an object with that key. + This does NOT work nested (e.g. an array which may be in any order cannot + contain arrays which itself may be in any order). ignore_value_keys only works + for single key entries. + :param ignore_value_keys: Same format as ignore_array_order_keys. All values wich are saved with a matching key + are ignored (May exists, May not exists, May have a different value than in + expected). + :param path: The path where expected is + :param only_print_errors: If true no exception will be raised + :return: The amount of errors/differences found + """ + if ignore_array_order_keys is None: + ignore_array_order_keys = [] + if ignore_value_keys is None: + ignore_value_keys = [] + if ApiTest._json_check_is_ignored(ignore_value_keys, path): + return 0 + if type(data) != type(expected): + msg = f"{path}: Expected type {type(expected)} got {type(data)} instead" + if only_print_errors: + print(msg) + return 1 + raise AssertionError(msg) + error_count = 0 + if isinstance(expected, dict): + assert isinstance(data, dict) + + missing_keys = [] + unexpected_keys = [] + for key in set(expected.keys()).union(set(data.keys())): + sub_path = f"{path}.{key}" + if ApiTest._json_check_is_ignored(ignore_value_keys, sub_path): + continue + if key in expected: + if key not in data: + missing_keys.append(key) + continue + error_count += self.assertJsonEqual(expected[key], data[key], + ignore_array_order_keys, + ignore_value_keys, + sub_path, + True) + else: + assert key in data + unexpected_keys.append(key) + + if len(missing_keys) > 0 or len(unexpected_keys) > 0: + error_count += 1 + print(f"{path}: Missing key(s): {missing_keys} Unexpected key(s): {unexpected_keys}") + elif isinstance(expected, list): + assert isinstance(data, list) + if len(data) != len(expected): + error_count += 1 + print(f"{path} Expected {len(expected)} value(s) but got {len(data)}") + else: + ignore_order = ApiTest._json_check_is_ignored(ignore_array_order_keys, path) + + if ignore_order: + expected_filtered = ApiTest._remove_values_with_keys(expected, ignore_value_keys) + data_filtered = ApiTest._remove_values_with_keys(data, ignore_value_keys) + missing_values = ApiTest._list_minus(expected_filtered, data_filtered) + unexpected_values = ApiTest._list_minus(data_filtered, expected_filtered) + assert len(missing_values) == len(unexpected_values) + + for expected_value, unexpected_value in zip(missing_values, unexpected_values): + error_count += self.assertJsonEqual(expected_value, unexpected_value, ignore_array_order_keys, + ignore_value_keys, + f"{path}[?]", + True) + else: + i: int = 0 + for expected_value, value in zip(expected, data): + error_count += self.assertJsonEqual(expected_value, value, ignore_array_order_keys, + ignore_value_keys, + f"{path}[{i}]", True) + i += 1 + else: + try: + self.assertEqual(expected, data, f"At {path}") + except Exception as e: + print(e) + error_count += 1 + if error_count > 0 and not only_print_errors: + raise AssertionError(f"Got {error_count} error(s)") + return error_count + + @staticmethod + def _remove_values_with_keys(object: dict or list, keys_to_remove: list[str]): + if isinstance(object, dict): + new_object = {} + for key, value in object.items(): + if key in keys_to_remove: + continue + if isinstance(value, (dict, list)): + value = ApiTest._remove_values_with_keys(value, keys_to_remove) + new_object[key] = value + return new_object + elif isinstance(object, list): + new_object = [] + for value in object: + if isinstance(value, (dict, list)): + value = ApiTest._remove_values_with_keys(value, keys_to_remove) + new_object.append(value) + return new_object + else: + raise TypeError(f"Got {object} but expected a dict or list") + + # noinspection PyMethodMayBeStatic + def assert_contains(self, collection, obj: object): + if obj not in collection: + raise AssertionError(f"{obj} not in {collection}") diff --git a/src/videoag_common/test/object_data.py b/src/videoag_common/test/object_data.py new file mode 100644 index 0000000000000000000000000000000000000000..bb10dfed27ee02bf1210ce8dda7d596872056c99 --- /dev/null +++ b/src/videoag_common/test/object_data.py @@ -0,0 +1,841 @@ +# -------------------- Chapters -------------------- + +TEST_DATA_CHAPTER_1 = \ + { + "start_time": 60, + "name": "test1" + } +TEST_DATA_CHAPTER_1_MOD = TEST_DATA_CHAPTER_1 | \ + { + "id": 1, + "is_visible": True + } + +TEST_DATA_CHAPTER_2 = \ + { + "start_time": 7200, + "name": "test2" + } +TEST_DATA_CHAPTER_2_MOD = TEST_DATA_CHAPTER_2 | \ + { + "id": 2, + "is_visible": True + } + +_TEST_DATA_CHAPTER_3 = \ + { + "start_time": 360, + "name": "Hidden" + } +TEST_DATA_CHAPTER_3_MOD = _TEST_DATA_CHAPTER_3 | \ + { + "id": 3, + "is_visible": False + } + +TEST_DATA_CHAPTER_5 = \ + { + "start_time": 429, + "name": "Something" + } +TEST_DATA_CHAPTER_5_MOD = TEST_DATA_CHAPTER_5 | \ + { + "id": 5, + "is_visible": True + } + +# -------------------- Media sources -------------------- + +TEST_DATA_MEDIA_SOURCE_185 = \ + { + "quality": { + "name": "720p", + "resolution": "1280x720", + "aspect_ration": "16:9", + "priority": 10 + }, + "size": 418148064, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/pub/09ss-fosap/09ss-fosap-090416.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/pub/09ss-fosap/09ss-fosap-090416.mp4" + } +TEST_DATA_MEDIA_SOURCE_185_MOD = TEST_DATA_MEDIA_SOURCE_185 | \ + { + "id": 185, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_186 = \ + { + "quality": { + "name": "720p", + "resolution": "1280x720", + "aspect_ration": "16:9", + "priority": 10 + }, + "size": 246922038, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/pub/09ss-fosap/09ss-fosap-090421.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/pub/09ss-fosap/09ss-fosap-090421.mp4" + } +TEST_DATA_MEDIA_SOURCE_186_MOD = TEST_DATA_MEDIA_SOURCE_186 | \ + { + "id": 186, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_204 = \ + { + "quality": { + "name": "1080p", + "resolution": "1920x1080", + "aspect_ration": "16:9", + "priority": 7 + }, + "size": 228716898, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/pub/09ss-fosap/09ss-fosap-090421.f4v", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/pub/09ss-fosap/09ss-fosap-090421.f4v" + } +TEST_DATA_MEDIA_SOURCE_204_MOD = TEST_DATA_MEDIA_SOURCE_204 | \ + { + "id": 204, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1366 = \ + { + "quality": { + "name": "Format unbekannt", + "resolution": "", + "aspect_ration": "", + "priority": 0 + }, + "size": 309620019, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/07ws-buk/07ws-buk-071019.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/07ws-buk/07ws-buk-071019.mp4" + } +TEST_DATA_MEDIA_SOURCE_1366_MOD = TEST_DATA_MEDIA_SOURCE_1366 | \ + { + "id": 1366, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1367 = \ + { + "quality": { + "name": "Format unbekannt", + "resolution": "", + "aspect_ration": "", + "priority": 0 + }, + "size": 442344305, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/07ws-buk/07ws-buk-071023.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/07ws-buk/07ws-buk-071023.mp4" + } +TEST_DATA_MEDIA_SOURCE_1367_MOD = TEST_DATA_MEDIA_SOURCE_1367 | \ + { + "id": 1367, + "is_visible": True + } + +_TEST_DATA_MEDIA_SOURCE_1368 = \ + { + "quality": { + "name": "Format unbekannt", + "resolution": "", + "aspect_ration": "", + "priority": 0 + }, + "size": 496109745, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/07ws-buk/07ws-buk-071026.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/07ws-buk/07ws-buk-071026.mp4", + } +TEST_DATA_MEDIA_SOURCE_1368_MOD = _TEST_DATA_MEDIA_SOURCE_1368 | \ + { + "id": 1368, + "is_visible": False + } + +TEST_DATA_MEDIA_SOURCE_1486 = \ + { + "quality": { + "name": "Format unbekannt", + "resolution": "", + "aspect_ration": "", + "priority": 0 + }, + "size": 398065123, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/pub/07ws-diskrete/07ws-diskrete-071218.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/pub/07ws-diskrete/07ws-diskrete-071218.mp4" + } +TEST_DATA_MEDIA_SOURCE_1486_MOD = TEST_DATA_MEDIA_SOURCE_1486 | \ + { + "id": 1486, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1487 = \ + { + "quality": { + "name": "Format unbekannt", + "resolution": "", + "aspect_ration": "", + "priority": 0 + }, + "size": 389368926, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/pub/07ws-diskrete/07ws-diskrete-080117.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/pub/07ws-diskrete/07ws-diskrete-080117.mp4" + } +TEST_DATA_MEDIA_SOURCE_1487_MOD = TEST_DATA_MEDIA_SOURCE_1487 | \ + { + "id": 1487, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1495 = \ + { + "quality": { + "name": "Format unbekannt", + "resolution": "", + "aspect_ration": "", + "priority": 0 + }, + "size": 549478861, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/pub/07ws-diskrete/07ws-diskrete-071211.mp4", + "download_url": "https://video.fsmpi.rwth-aachen.de/files/pub/07ws-diskrete/07ws-diskrete-071211.mp4" + } +TEST_DATA_MEDIA_SOURCE_1495_MOD = TEST_DATA_MEDIA_SOURCE_1495 | \ + { + "id": 1495, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1497 = \ + { + "quality": { + "name": "720p", + "resolution": "1280x720", + "aspect_ration": "16:9", + "priority": 10 + }, + "size": 687254584, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/11ws-infin/11ws-infin-111010.mp4" + } +TEST_DATA_MEDIA_SOURCE_1497_MOD = TEST_DATA_MEDIA_SOURCE_1497 | \ + { + "id": 1497, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1539 = \ + { + "quality": { + "name": "720p", + "resolution": "1280x720", + "aspect_ration": "16:9", + "priority": 10 + }, + "size": 1080865275, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/11ws-infin/11ws-infin-111017.mp4" + } +TEST_DATA_MEDIA_SOURCE_1539_MOD = TEST_DATA_MEDIA_SOURCE_1539 | \ + { + "id": 1539, + "is_visible": True + } + +TEST_DATA_MEDIA_SOURCE_1540 = \ + { + "quality": { + "name": "720p", + "resolution": "1280x720", + "aspect_ration": "16:9", + "priority": 10 + }, + "size": 1042222975, + "comment": "", + "player_url": "https://video.fsmpi.rwth-aachen.de/files/vpnonline/11ws-infin/11ws-infin-111024.mp4" + } +TEST_DATA_MEDIA_SOURCE_1540_MOD = TEST_DATA_MEDIA_SOURCE_1540 | \ + { + "id": 1540, + "is_visible": True + } + +# -------------------- Lectures -------------------- + +TEST_DATA_LECTURE_1_NO_CHAP_MEDIA = \ + { + "id": 1, + "course_id": 2, + "title": "Einführung zur Berechenbarkeit", + "speaker": "", + "location": "", + "time": "2007-10-19T12:00:00", + "duration": 0, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_1.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": [ + "rwth", + "fsmpi" + ] + } +TEST_DATA_LECTURE_1_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_1_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_1 = TEST_DATA_LECTURE_1_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1366] +} +TEST_DATA_LECTURE_1_MOD = TEST_DATA_LECTURE_1_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1366_MOD] +} + +TEST_DATA_LECTURE_2_NO_CHAP_MEDIA = \ + { + "id": 2, + "course_id": 2, + "title": "Einführung zur Berechenbarkeit", + "speaker": "", + "location": "", + "time": "2007-10-23T08:30:00", + "duration": 0, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_2.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": [ + "rwth", + "fsmpi" + ] + } +TEST_DATA_LECTURE_2_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_2_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_2 = TEST_DATA_LECTURE_2_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1367] +} +TEST_DATA_LECTURE_2_MOD = TEST_DATA_LECTURE_2_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1367_MOD] +} + +TEST_DATA_LECTURE_3_NO_CHAP_MEDIA = \ + { + "id": 3, + "course_id": 2, + "title": "Einführung zur Berechenbarkeit", + "speaker": "", + "location": "", + "time": "2007-10-26T12:00:00", + "duration": 0, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_3.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": ["public"] + } +TEST_DATA_LECTURE_3_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_3_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_3 = TEST_DATA_LECTURE_3_NO_CHAP_MEDIA | { + "chapters": [TEST_DATA_CHAPTER_5], + "media_sources": [] +} +TEST_DATA_LECTURE_3_MOD = TEST_DATA_LECTURE_3_NO_CHAP_MEDIA_MOD | { + "chapters": [TEST_DATA_CHAPTER_5_MOD], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1368_MOD] +} + +TEST_DATA_LECTURE_25_NO_CHAP_MEDIA = \ + { + "id": 25, + "course_id": 3, + "title": "Graphentheorie: Grundbegriffe, Datenstrukturen, Algorithmus für Breitensuche", + "speaker": "", + "location": "", + "time": "2007-12-11T13:30:00", + "duration": 0, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_25.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": False, + "authentication_methods": ["public"] + } +TEST_DATA_LECTURE_25_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_25_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_25 = TEST_DATA_LECTURE_25_NO_CHAP_MEDIA | { + "chapters": [TEST_DATA_CHAPTER_1, TEST_DATA_CHAPTER_2], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1495] +} +TEST_DATA_LECTURE_25_MOD = TEST_DATA_LECTURE_25_NO_CHAP_MEDIA_MOD | { + "chapters": [TEST_DATA_CHAPTER_1_MOD, TEST_DATA_CHAPTER_2_MOD], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1495_MOD] +} + +_TEST_DATA_LECTURE_26_NO_CHAP_MEDIA = \ + { + "id": 26, + "course_id": 3, + "title": "Hamiltonkreis, Eulertour, Eulerweg", + "speaker": "", + "location": "", + "time": "2007-12-18T13:30:00", + "duration": 0, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_26.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": False, + "authentication_methods": [] + } +TEST_DATA_LECTURE_26_NO_CHAP_MEDIA_MOD = _TEST_DATA_LECTURE_26_NO_CHAP_MEDIA | \ + { + "is_visible": False, + "internal_comment": "", + } +TEST_DATA_LECTURE_26_MOD = TEST_DATA_LECTURE_26_NO_CHAP_MEDIA_MOD | { + "chapters": [TEST_DATA_CHAPTER_3_MOD], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1486_MOD] +} + +TEST_DATA_LECTURE_29_NO_CHAP_MEDIA = \ + { + "id": 29, + "course_id": 3, + "title": "Modulare Arithmetik: Gruppe, Ring, Körper, abelsche Gruppe, Untergruppe, Einheitengruppe. Restklassenringe, Primzahl.", + "speaker": "", + "location": "", + "time": "2008-01-17T08:15:00", + "duration": 0, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_29.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": False, + "authentication_methods": ["password"] + } +TEST_DATA_LECTURE_29_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_29_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_29 = TEST_DATA_LECTURE_29_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1487] +} +TEST_DATA_LECTURE_29_MOD = TEST_DATA_LECTURE_29_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1487_MOD] +} + +TEST_DATA_LECTURE_185_NO_CHAP_MEDIA = \ + { + "id": 185, + "course_id": 13, + "title": "Organisatorisches, Motivation, Künstliche Pflanzen, Alphabete, Wörter, Sprachen", + "speaker": "", + "location": "", + "time": "2009-04-16T10:00:00", + "duration": 90, + "description": "Sorry für den schlechten Ton", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_185.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": [ + "public" + ] + } +TEST_DATA_LECTURE_185_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_185_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "", + } +TEST_DATA_LECTURE_185 = TEST_DATA_LECTURE_185_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_185] +} +TEST_DATA_LECTURE_185_MOD = TEST_DATA_LECTURE_185_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_185_MOD] +} + +TEST_DATA_LECTURE_186_NO_CHAP_MEDIA = \ + { + "id": 186, + "course_id": 13, + "title": "Alphabete, Wörter, Sprachen, Reguläre Ausdrücke", + "speaker": "", + "location": "", + "time": "2009-04-21T08:15:00", + "duration": 45, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_186.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": [ + "public" + ] + } +TEST_DATA_LECTURE_186_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_186_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "", + } +TEST_DATA_LECTURE_186 = TEST_DATA_LECTURE_186_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_186, TEST_DATA_MEDIA_SOURCE_204] +} +TEST_DATA_LECTURE_186_MOD = TEST_DATA_LECTURE_186_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_186_MOD, TEST_DATA_MEDIA_SOURCE_204_MOD] +} + +TEST_DATA_LECTURE_1186_NO_CHAP_MEDIA = \ + { + "id": 1186, + "course_id": 62, + "title": "Einführung, I. Grundlagen", + "speaker": "", + "location": "Aula", + "time": "2011-10-10T18:30:00", + "duration": 90, + "description": "", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_1186.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": ["moodle"] + } +TEST_DATA_LECTURE_1186_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_1186_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_1186 = TEST_DATA_LECTURE_1186_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1497] +} +TEST_DATA_LECTURE_1186_MOD = TEST_DATA_LECTURE_1186_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1497_MOD] +} + +TEST_DATA_LECTURE_1187_NO_CHAP_MEDIA = \ + { + "id": 1187, + "course_id": 62, + "title": "", + "speaker": "", + "location": "Aula", + "time": "2011-10-17T18:30:00", + "duration": 90, + "description": "noch kein Titel", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_1187.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": ["moodle"] + } +TEST_DATA_LECTURE_1187_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_1187_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_1187 = TEST_DATA_LECTURE_1187_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1539] +} +TEST_DATA_LECTURE_1187_MOD = TEST_DATA_LECTURE_1187_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1539_MOD] +} + +TEST_DATA_LECTURE_1188_NO_CHAP_MEDIA = \ + { + "id": 1188, + "course_id": 62, + "title": "", + "speaker": "", + "location": "Aula", + "time": "2050-10-24T18:30:00", + "duration": 90, + "description": "noch kein Titel", + "thumbnail_url": "https://video.fsmpi.rwth-aachen.de/files/thumbnail/l_1188.jpg", + "no_recording": False, + "livestream_planned": False, + "allow_embed": True, + "authentication_methods": ["moodle"] + } +TEST_DATA_LECTURE_1188_NO_CHAP_MEDIA_MOD = TEST_DATA_LECTURE_1188_NO_CHAP_MEDIA | \ + { + "is_visible": True, + "internal_comment": "" + } +TEST_DATA_LECTURE_1188 = TEST_DATA_LECTURE_1188_NO_CHAP_MEDIA | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1540] +} +TEST_DATA_LECTURE_1188_MOD = TEST_DATA_LECTURE_1188_NO_CHAP_MEDIA_MOD | { + "chapters": [], + "media_sources": [TEST_DATA_MEDIA_SOURCE_1540_MOD] +} + +# -------------------- Courses -------------------- + +TEST_DATA_COURSE_2_NO_LEC = \ + { + "id": 2, + "id_string": "07ws-buk", + "full_name": "Berechenbarkeit und Komplexität", + "short_name": "BuK", + "organizer": "Prof. Vöcking", + "topic": "Informatik", + "description": "Seite zur Veranstaltung...", + "show_chapters_on_course": False, + "semester": "2007ws", + "default_authentication_methods": [ + "rwth", + "fsmpi" + ] + } +TEST_DATA_COURSE_2_NO_LEC_MOD = TEST_DATA_COURSE_2_NO_LEC | \ + { + "is_listed": True, + "is_visible": True + } +TEST_DATA_COURSE_2 = TEST_DATA_COURSE_2_NO_LEC | { + "lectures": [TEST_DATA_LECTURE_1, TEST_DATA_LECTURE_2, TEST_DATA_LECTURE_3] +} +TEST_DATA_COURSE_2_MOD = TEST_DATA_COURSE_2_NO_LEC_MOD | { + "lectures": [TEST_DATA_LECTURE_1_MOD, TEST_DATA_LECTURE_2_MOD, TEST_DATA_LECTURE_3_MOD] +} + +TEST_DATA_COURSE_3_NO_LEC = \ + { + "id": 3, + "id_string": "07ws-diskrete", + "full_name": "Diskrete Strukturen", + "short_name": "Diskrete", + "organizer": "Prof. Hiß", + "topic": "Informatik", + "description": "Von dieser Vorlesungsreihe fehlen die ersten zwei Monate. Wenn wir die Gelegenheit bekommen, filmen wir gerne nochmal.", + "show_chapters_on_course": False, + "semester": "2007ws", + "default_authentication_methods": [] + } +TEST_DATA_COURSE_3_NO_LEC_MOD = TEST_DATA_COURSE_3_NO_LEC | \ + { + "is_listed": True, + "is_visible": True + } +TEST_DATA_COURSE_3 = TEST_DATA_COURSE_3_NO_LEC | { + "lectures": [TEST_DATA_LECTURE_25, TEST_DATA_LECTURE_29] +} +TEST_DATA_COURSE_3_MOD = TEST_DATA_COURSE_3_NO_LEC_MOD | { + "lectures": [TEST_DATA_LECTURE_25_MOD, TEST_DATA_LECTURE_26_MOD, TEST_DATA_LECTURE_29_MOD] +} + +_TEST_DATA_COURSE_13_NO_LEC = \ + { + "id": 13, + "id_string": "09ss-fosap", + "full_name": "Formale Systeme, Automaten, Prozesse", + "short_name": "FoSAP", + "organizer": "Prof. Rossmanith", + "topic": "Informatik", + "description": "Seite des Lehrstuhls ...", + "show_chapters_on_course": False, + "semester": "2009ss", + "default_authentication_methods": [ + "public" + ] + } +TEST_DATA_COURSE_13_NO_LEC_MOD = _TEST_DATA_COURSE_13_NO_LEC | \ + { + "is_listed": True, + "is_visible": False + } +TEST_DATA_COURSE_13_MOD = TEST_DATA_COURSE_13_NO_LEC_MOD | { + "lectures": [TEST_DATA_LECTURE_185_MOD, TEST_DATA_LECTURE_186_MOD] +} + +TEST_DATA_COURSE_62_NO_LEC = \ + { + "id": 62, + "id_string": "11ws-infin", + "full_name": "Investition und Finanzierung", + "short_name": "InFin", + "organizer": "Prof. Breuer", + "topic": "BWL", + "description": "Seite im Campus ...", + "show_chapters_on_course": False, + "semester": "2011ws", + "default_authentication_methods": [ + "moodle" + ] + } +TEST_DATA_COURSE_62_NO_LEC_MOD = TEST_DATA_COURSE_62_NO_LEC | \ + { + "is_listed": False, + "is_visible": True + } +TEST_DATA_COURSE_62 = TEST_DATA_COURSE_62_NO_LEC | { + "lectures": [TEST_DATA_LECTURE_1186, TEST_DATA_LECTURE_1187, TEST_DATA_LECTURE_1188] +} +TEST_DATA_COURSE_62_MOD = TEST_DATA_COURSE_62_NO_LEC_MOD | { + "lectures": [TEST_DATA_LECTURE_1186_MOD, TEST_DATA_LECTURE_1187_MOD, TEST_DATA_LECTURE_1188_MOD] +} + +# -------------------- Announcements -------------------- + +TEST_DATA_ANNOUNCEMENT_1 = \ + { + "id": 1, + "text": "Test Ankündigung", + "type": "info", + "visibility": "only_main_page" + } +TEST_DATA_ANNOUNCEMENT_1_MOD = TEST_DATA_ANNOUNCEMENT_1 | \ + { + "is_currently_visible": True, + "has_expired": False + } + +TEST_DATA_ANNOUNCEMENT_2 = \ + { + "id": 2, + "text": "Neue Ankündigung", + "type": "info", + "visibility": "all_pages" + } +TEST_DATA_ANNOUNCEMENT_2_MOD = TEST_DATA_ANNOUNCEMENT_2 | \ + { + "is_currently_visible": True, + "has_expired": False + } + +_TEST_DATA_ANNOUNCEMENT_3 = \ + { + "id": 3, + "text": "Versteckte Ankündigung", + "type": "info", + "visibility": "all_pages" + } +TEST_DATA_ANNOUNCEMENT_3_MOD = _TEST_DATA_ANNOUNCEMENT_3 | \ + { + "is_currently_visible": False, + "has_expired": False + } + +_TEST_DATA_ANNOUNCEMENT_4 = \ + { + "id": 4, + "text": "Upcoming Announcement", + "type": "warning", + "visibility": "all_pages" + } +TEST_DATA_ANNOUNCEMENT_4_MOD = _TEST_DATA_ANNOUNCEMENT_4 | \ + { + "is_currently_visible": False, + "has_expired": False + } + +_TEST_DATA_ANNOUNCEMENT_5 = \ + { + "id": 5, + "text": "Expired Announcement", + "type": "info", + "visibility": "only_main_page" + } +TEST_DATA_ANNOUNCEMENT_5_MOD = _TEST_DATA_ANNOUNCEMENT_5 | \ + { + "is_currently_visible": False, + "has_expired": True + } + +# -------------------- Featured -------------------- + +TEST_DATA_FEATURED_1 = \ + { + "type": "plain", + "title": "Video AG", + "text": "Wir machen Vorlesungsvideos" + } +TEST_DATA_FEATURED_1_MOD = TEST_DATA_FEATURED_1 | \ + { + "id": 1, + "is_visible": True, + "display_priority": 3 + } + +_TEST_DATA_FEATURED_2 = \ + { + "type": "image", + "title": "Image Panel", + "text": "", + "image_url": "https://example.com/image.jpg" + } +TEST_DATA_FEATURED_2_MOD = _TEST_DATA_FEATURED_2 | \ + { + "id": 2, + "is_visible": False, + "display_priority": 0 + } + +TEST_DATA_FEATURED_3 = \ + { + "type": "course_list", + "title": "Courses Panel", + "text": "Vom Winter 07", + "courses": [2, 3] + } +TEST_DATA_FEATURED_3_MOD = TEST_DATA_FEATURED_3 | \ + { + "id": 3, + "is_visible": True, + "display_priority": 1 + } + +TEST_DATA_FEATURED_4 = \ + { + "type": "lecture", + "title": "Lecture Panel", + "text": "Watch this!", + "lecture": TEST_DATA_LECTURE_3_NO_CHAP_MEDIA + } +TEST_DATA_FEATURED_4_MOD = TEST_DATA_FEATURED_4 | \ + { + "lecture": TEST_DATA_LECTURE_3_NO_CHAP_MEDIA_MOD, + "id": 4, + "is_visible": True, + "display_priority": 2 + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391