feat: created and last_modified model attributes

This commit is contained in:
Éloi Rivard 2024-03-27 13:53:46 +01:00
parent 62d29a00bb
commit ffa12b0f71
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
10 changed files with 611 additions and 502 deletions

View file

@ -9,6 +9,7 @@ Changed
- Use default python logging configuration format. :issue:`188` :pr:`165`
- Bump to htmx 1.99.11 :pr:`166`
- Use the standard tomllib python module instead of `toml` starting from python 3.11 :pr:`167`
- Add `created` and `last_modified` datetime for all models
[0.0.42] - 2023-12-29
=====================

View file

@ -174,7 +174,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass):
if ldap_name == "dn":
return self.dn_for(self.rdn_value)
python_single_value = "List" not in str(self.__annotations__[name])
python_single_value = "List" not in str(self.attributes[name])
ldap_value = self.get_ldap_attribute(ldap_name)
return cardinalize_attribute(python_single_value, ldap_value)

View file

@ -18,6 +18,8 @@ class User(canaille.core.models.User, LDAPObject):
attribute_map = {
"id": "dn",
"created": "createTimestamp",
"last_modified": "modifyTimestamp",
"user_name": "uid",
"password": "userPassword",
"preferred_language": "preferredLanguage",
@ -38,7 +40,6 @@ class User(canaille.core.models.User, LDAPObject):
"department": "departmentNumber",
"title": "title",
"organization": "o",
"last_modified": "modifyTimestamp",
"groups": "memberOf",
"lock_date": "pwdEndTime",
}
@ -197,6 +198,8 @@ class Group(canaille.core.models.Group, LDAPObject):
attribute_map = {
"id": "dn",
"created": "createTimestamp",
"last_modified": "modifyTimestamp",
"display_name": "cn",
"members": "member",
"description": "description",
@ -246,6 +249,8 @@ class Client(canaille.oidc.models.Client, LDAPObject):
attribute_map = {
"id": "dn",
"created": "createTimestamp",
"last_modified": "modifyTimestamp",
"preconsent": "oauthPreconsent",
# post_logout_redirect_uris is not yet supported by authlib
"post_logout_redirect_uris": "oauthPostLogoutRedirectURI",
@ -265,6 +270,8 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, LDAPObject):
rdn_attribute = "oauthAuthorizationCodeID"
attribute_map = {
"id": "dn",
"created": "createTimestamp",
"last_modified": "modifyTimestamp",
"authorization_code_id": "oauthAuthorizationCodeID",
"code": "oauthCode",
"client": "oauthClient",
@ -291,6 +298,8 @@ class Token(canaille.oidc.models.Token, LDAPObject):
rdn_attribute = "oauthTokenID"
attribute_map = {
"id": "dn",
"created": "createTimestamp",
"last_modified": "modifyTimestamp",
"token_id": "oauthTokenID",
"access_token": "oauthAccessToken",
"client": "oauthClient",
@ -315,6 +324,8 @@ class Consent(canaille.oidc.models.Consent, LDAPObject):
rdn_attribute = "cn"
attribute_map = {
"id": "dn",
"created": "createTimestamp",
"last_modified": "modifyTimestamp",
"consent_id": "cn",
"subject": "oauthSubject",
"client": "oauthClient",

View file

@ -85,6 +85,12 @@ class MemoryModel(Model):
return results[0] if results else None
def save(self):
self.last_modified = datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0
)
if not self.created:
self.created = self.last_modified
self.delete()
# update the id index
@ -190,7 +196,7 @@ class MemoryModel(Model):
]
values = [value for value in values if value]
unique_attribute = "List" not in str(self.__annotations__[name])
unique_attribute = "List" not in str(self.attributes[name])
if unique_attribute:
return values[0] if values else None
else:

View file

@ -1,4 +1,6 @@
import datetime
from collections import ChainMap
from typing import Optional
from canaille.app import classproperty
@ -6,6 +8,20 @@ from canaille.app import classproperty
class Model:
"""Model abstract class."""
created: Optional[datetime.datetime]
"""The "DateTime" that the resource was added to the service provider.
This attribute MUST be a DateTime.
"""
last_modified: Optional[datetime.datetime]
"""The most recent DateTime that the details of this resource were updated
at the service provider.
If this resource has never been modified since its initial creation,
the value MUST be the same as the value of "created".
"""
@classproperty
def attributes(cls):
return ChainMap(

View file

@ -60,7 +60,7 @@ class SqlAlchemyModel(Model):
filter = or_(
getattr(cls, attribute_name).ilike(f"%{query}%")
for attribute_name in attributes
if "str" in str(cls.__annotations__[attribute_name])
if "str" in str(cls.attributes[attribute_name])
)
return (
@ -72,7 +72,7 @@ class SqlAlchemyModel(Model):
if isinstance(value, list):
return or_(cls.attribute_filter(name, v) for v in value)
multiple = "List" in str(cls.__annotations__[name])
multiple = "List" in str(cls.attributes[name])
if multiple:
return getattr(cls, name).contains(value)
@ -98,6 +98,12 @@ class SqlAlchemyModel(Model):
return getattr(self, self.identifier_attribute)
def save(self):
self.last_modified = datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0
)
if not self.created:
self.created = self.last_modified
Backend.get().db_session.add(self)
Backend.get().db_session.commit()
@ -124,6 +130,13 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
created: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
user_name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password: Mapped[str] = mapped_column(
PasswordType(schemes=["pbkdf2_sha512"]), nullable=True
@ -146,9 +159,6 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
department: Mapped[str] = mapped_column(String, nullable=True)
title: Mapped[str] = mapped_column(String, nullable=True)
organization: Mapped[str] = mapped_column(String, nullable=True)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
groups: Mapped[List["Group"]] = relationship(
secondary=membership_association_table, back_populates="members"
)
@ -193,7 +203,7 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
return all(
self.normalize_filter_value(attribute, value)
in getattr(self, attribute, [])
if "List" in str(self.__annotations__[attribute])
if "List" in str(self.attributes[attribute])
else self.normalize_filter_value(attribute, value)
== getattr(self, attribute, None)
for attribute, value in filter.items()
@ -221,12 +231,6 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
self.password = password
self.save()
def save(self):
self.last_modified = datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0
)
super().save()
class Group(canaille.core.models.Group, Base, SqlAlchemyModel):
__tablename__ = "group"
@ -235,6 +239,13 @@ class Group(canaille.core.models.Group, Base, SqlAlchemyModel):
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
created: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
display_name: Mapped[str] = mapped_column(String)
description: Mapped[str] = mapped_column(String, nullable=True)
members: Mapped[List["User"]] = relationship(
@ -252,11 +263,17 @@ client_audience_association_table = Table(
class Client(canaille.oidc.models.Client, Base, SqlAlchemyModel):
__tablename__ = "client"
identifier_attribute = "client_id"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
identifier_attribute = "client_id"
created: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
description: Mapped[str] = mapped_column(String, nullable=True)
preconsent: Mapped[bool] = mapped_column(Boolean, nullable=True)
@ -301,6 +318,12 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Base, SqlAlchemy
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
created: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
authorization_code_id: Mapped[str] = mapped_column(String, nullable=True)
code: Mapped[str] = mapped_column(String, nullable=True)
@ -338,6 +361,12 @@ class Token(canaille.oidc.models.Token, Base, SqlAlchemyModel):
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
created: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
token_id: Mapped[str] = mapped_column(String, nullable=True)
access_token: Mapped[str] = mapped_column(String, nullable=True)
@ -370,6 +399,12 @@ class Consent(canaille.oidc.models.Consent, Base, SqlAlchemyModel):
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
created: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_modified: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
consent_id: Mapped[str] = mapped_column(String, nullable=True)
subject_id: Mapped[str] = mapped_column(ForeignKey("user.id"))

View file

@ -223,14 +223,6 @@ class User:
department: Optional[str]
"""Identifies the name of a department."""
last_modified: Optional[datetime.datetime]
"""The most recent DateTime that the details of this resource were updated
at the service provider.
If this resource has never been modified since its initial creation,
the value MUST be the same as the value of "created".
"""
groups: List["Group"]
"""A list of groups to which the user belongs, either through direct
membership, through nested groups, or dynamically calculated.

View file

@ -39,6 +39,9 @@
<div class="content">
{% trans %}Account settings{% endtrans %}
</div>
<div class="sub header" title="{{ edited_user.created|datetimeformat }}">
{% trans creation_datetime=edited_user.created|dateformat %}Created on {{ creation_datetime }}{% endtrans %}
</div>
</h2>
{% call fui.render_form(form, class_="profile-form info warning") %}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,6 @@
import datetime
import freezegun
import pytest
from canaille.app import models
@ -236,3 +239,36 @@ def test_model_references_set_unsaved_object(
testclient.get("/groups/foo", status=200)
group.delete()
def test_model_creation_edition_datetime(testclient, backend):
if "ldap" in backend.__class__.__module__:
pytest.skip()
with freezegun.freeze_time("2020-01-01 02:00:00"):
user = models.User(
user_name="foo",
family_name="foo",
formatted_name="foo",
)
user.save()
user.reload()
assert user.created == datetime.datetime(
2020, 1, 1, 2, tzinfo=datetime.timezone.utc
)
assert user.last_modified == datetime.datetime(
2020, 1, 1, 2, tzinfo=datetime.timezone.utc
)
with freezegun.freeze_time("2021-01-01 02:00:00"):
user.family_name = "bar"
user.save()
user.reload()
assert user.created == datetime.datetime(
2020, 1, 1, 2, tzinfo=datetime.timezone.utc
)
assert user.last_modified == datetime.datetime(
2021, 1, 1, 2, tzinfo=datetime.timezone.utc
)
user.delete()