doc: model documentation

This commit is contained in:
Éloi Rivard 2023-08-17 15:55:41 +02:00
parent b213610903
commit 553595c5ed
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
11 changed files with 213 additions and 53 deletions

View file

@ -3,6 +3,7 @@ from collections.abc import Iterable
import ldap.dn import ldap.dn
import ldap.filter import ldap.filter
from canaille.backends.models import Model
from .backend import Backend from .backend import Backend
from .utils import ldap_to_python from .utils import ldap_to_python
@ -100,7 +101,7 @@ class LDAPObjectQuery:
return klass return klass
class LDAPObject(metaclass=LDAPObjectMetaclass): class LDAPObject(Model, metaclass=LDAPObjectMetaclass):
_object_class_by_name = None _object_class_by_name = None
_attribute_type_by_name = None _attribute_type_by_name = None
_may = None _may = None

View file

@ -5,6 +5,7 @@ import uuid
import canaille.core.models import canaille.core.models
import canaille.oidc.models import canaille.oidc.models
from canaille.app import models from canaille.app import models
from canaille.backends.models import Model
from flask import current_app from flask import current_app
@ -13,10 +14,10 @@ def listify(value):
def serialize(value): def serialize(value):
return value.id if isinstance(value, Model) else value return value.id if isinstance(value, MemoryModel) else value
class Model: class MemoryModel(Model):
indexes = {} indexes = {}
attribute_indexes = {} attribute_indexes = {}
@ -51,11 +52,11 @@ class Model:
if not class_name: if not class_name:
class_name = cls.__name__ class_name = cls.__name__
return Model.indexes.setdefault(class_name, {}).setdefault("id", {}) return MemoryModel.indexes.setdefault(class_name, {}).setdefault("id", {})
@classmethod @classmethod
def attribute_index(cls, attribute="id", class_name=None): def attribute_index(cls, attribute="id", class_name=None):
return Model.attribute_indexes.setdefault( return MemoryModel.attribute_indexes.setdefault(
class_name or cls.__name__, {} class_name or cls.__name__, {}
).setdefault(attribute, {}) ).setdefault(attribute, {})
@ -172,7 +173,7 @@ class Model:
if other is None: if other is None:
return False return False
if not isinstance(other, Model): if not isinstance(other, MemoryModel):
return self == self.__class__.get(id=other) return self == self.__class__.get(id=other)
return self.state == other.state return self.state == other.state
@ -187,7 +188,7 @@ class Model:
if name in self.model_attributes: if name in self.model_attributes:
klass = getattr(models, self.model_attributes[name][0]) klass = getattr(models, self.model_attributes[name][0])
values = [ values = [
value if isinstance(value, Model) else klass.get(id=value) value if isinstance(value, MemoryModel) else klass.get(id=value)
for value in values for value in values
] ]
values = [value for value in values if value] values = [value for value in values if value]
@ -222,7 +223,7 @@ class Model:
pass pass
class User(canaille.core.models.User, Model): class User(canaille.core.models.User, MemoryModel):
attributes = [ attributes = [
"id", "id",
"user_name", "user_name",
@ -333,7 +334,7 @@ class User(canaille.core.models.User, Model):
super().save() super().save()
class Group(canaille.core.models.Group, Model): class Group(canaille.core.models.Group, MemoryModel):
attributes = [ attributes = [
"id", "id",
"display_name", "display_name",
@ -351,7 +352,7 @@ class Group(canaille.core.models.Group, Model):
return getattr(self, self.identifier_attribute) return getattr(self, self.identifier_attribute)
class Client(canaille.oidc.models.Client, Model): class Client(canaille.oidc.models.Client, MemoryModel):
attributes = [ attributes = [
"id", "id",
"description", "description",
@ -406,7 +407,7 @@ class Client(canaille.oidc.models.Client, Model):
return getattr(self, self.identifier_attribute) return getattr(self, self.identifier_attribute)
class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Model): class AuthorizationCode(canaille.oidc.models.AuthorizationCode, MemoryModel):
attributes = [ attributes = [
"id", "id",
"authorization_code_id", "authorization_code_id",
@ -449,7 +450,7 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Model):
return getattr(self, self.identifier_attribute) return getattr(self, self.identifier_attribute)
class Token(canaille.oidc.models.Token, Model): class Token(canaille.oidc.models.Token, MemoryModel):
attributes = [ attributes = [
"id", "id",
"token_id", "token_id",
@ -488,7 +489,7 @@ class Token(canaille.oidc.models.Token, Model):
return getattr(self, self.identifier_attribute) return getattr(self, self.identifier_attribute)
class Consent(canaille.oidc.models.Consent, Model): class Consent(canaille.oidc.models.Consent, MemoryModel):
attributes = [ attributes = [
"id", "id",
"consent_id", "consent_id",

View file

@ -0,0 +1,93 @@
class Model:
"""
Model abstract class.
"""
@classmethod
def query(cls, **kwargs):
"""
Performs a query on the database and return a collection of instances.
Parameters can be any valid attribute with the expected value:
>>> User.query(first_name="George")
If several arguments are passed, the methods only returns the model
instances that return matches all the argument values:
>>> User.query(first_name="George", last_name="Abitbol")
If the argument value is a collection, the methods will return the
models that matches any of the values:
>>> User.query(first_name=["George", "Jane"])
"""
raise NotImplementedError()
@classmethod
def fuzzy(cls, query, attributes=None, **kwargs):
"""
Works like :meth:`~canaille.app.models.Model.query` but attribute values
loosely be matched.
"""
raise NotImplementedError()
@classmethod
def get(cls, identifier=None, **kwargs):
"""
Works like :meth:`~canaille.app.models.Model.query` but return only one
element or :const:`None` if no item is matching.
"""
raise NotImplementedError()
@property
def identifier(self):
"""
Returns a unique value that will be used to identify the model instance.
This value will be used in URLs in canaille, so it should be unique and short.
"""
raise NotImplementedError()
def save(self):
"""
Validates the current modifications in the database.
"""
raise NotImplementedError()
def delete(self):
"""
Removes the current instance from the database.
"""
raise NotImplementedError()
def update(self, **kwargs):
"""
Assign a whole dict to the current instance. This is useful to update
models based on forms.
>>> user = User.get(user_name="george")
>>> user.first_name
George
>>> user.update({
... first_name="Jane",
... last_name="Calamity",
... })
>>> user.first_name
Jane
"""
raise NotImplementedError()
def reload(self):
"""
Cancels the unsaved modifications.
>>> user = User.get(user_name="george")
>>> user.display_name
George
>>> user.display_name = "Jane"
>>> user.display_name
Jane
>>> user.reload()
>>> user.display_name
George
"""
raise NotImplementedError()

View file

@ -1,10 +1,15 @@
import datetime import datetime
from typing import Optional
from flask import g from flask import g
from flask import session from flask import session
class User: class User:
"""
User model, based on the `SCIM User schema <https://datatracker.ietf.org/doc/html/rfc7643#section-4.1>`_
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.read = set() self.read = set()
self.write = set() self.write = set()
@ -12,10 +17,13 @@ class User:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@classmethod @classmethod
def get_from_login(cls, login=None, **kwargs): def get_from_login(cls, login=None, **kwargs) -> Optional["User"]:
raise NotImplementedError() raise NotImplementedError()
def login(self): def login(self):
"""
Opens a session for the user.
"""
g.user = self g.user = self
try: try:
previous = ( previous = (
@ -29,6 +37,9 @@ class User:
@classmethod @classmethod
def logout(self): def logout(self):
"""
Closes the user session.
"""
try: try:
session["user_id"].pop() session["user_id"].pop()
del g.user del g.user
@ -37,24 +48,25 @@ class User:
except (IndexError, KeyError): except (IndexError, KeyError):
pass pass
@property def has_password(self) -> bool:
def identifier(self):
""" """
Returns a unique value that will be used to identify the user. Checks wether a password has been set for the user.
This value will be used in URLs in canaille, so it should be unique and short.
""" """
raise NotImplementedError() raise NotImplementedError()
def has_password(self): def check_password(self, password: str) -> bool:
"""
Checks if the password matches the user password in the database.
"""
raise NotImplementedError() raise NotImplementedError()
def check_password(self, password): def set_password(self, password: str):
"""
Sets a password for the user.
"""
raise NotImplementedError() raise NotImplementedError()
def set_password(self, password): def can_read(self, field: str):
raise NotImplementedError()
def can_read(self, field):
return field in self.read | self.write return field in self.read | self.write
@property @property
@ -69,17 +81,16 @@ class User:
return super().__getattr__(name) return super().__getattr__(name)
@property @property
def locked(self): def locked(self) -> bool:
"""
Wether the user account has been locked or has expired.
"""
return bool(self.lock_date) and self.lock_date < datetime.datetime.now( return bool(self.lock_date) and self.lock_date < datetime.datetime.now(
datetime.timezone.utc datetime.timezone.utc
) )
class Group: class Group:
@property """
def identifier(self): User model, based on the `SCIM Group schema <https://datatracker.ietf.org/doc/html/rfc7643#section-4.2>`_
""" """
Returns a unique value that will be used to identify the user.
This value will be used in URLs in canaille, so it should be unique and short.
"""
raise NotImplementedError()

View file

@ -8,6 +8,10 @@ from canaille.app import models
class Client(ClientMixin): class Client(ClientMixin):
"""
OpenID Connect client definition.
"""
client_info_attributes = [ client_info_attributes = [
"client_id", "client_id",
"client_secret", "client_secret",
@ -95,6 +99,10 @@ class Client(ClientMixin):
class AuthorizationCode(AuthorizationCodeMixin): class AuthorizationCode(AuthorizationCodeMixin):
"""
OpenID Connect temporary authorization code definition.
"""
def get_redirect_uri(self): def get_redirect_uri(self):
return self.redirect_uri return self.redirect_uri
@ -119,6 +127,10 @@ class AuthorizationCode(AuthorizationCodeMixin):
class Token(TokenMixin): class Token(TokenMixin):
"""
OpenID Connect token definition.
"""
@property @property
def expire_date(self): def expire_date(self):
return self.issue_date + datetime.timedelta(seconds=int(self.lifetime)) return self.issue_date + datetime.timedelta(seconds=int(self.lifetime))
@ -164,6 +176,10 @@ class Token(TokenMixin):
class Consent: class Consent:
"""
Long-term user consent to an application.
"""
@property @property
def revoked(self): def revoked(self):
return bool(self.revokation_date) return bool(self.revokation_date)

View file

@ -39,6 +39,7 @@ Table of contents
configuration configuration
troubleshooting troubleshooting
contributing contributing
reference
specifications specifications
changelog changelog

13
doc/reference.rst Normal file
View file

@ -0,0 +1,13 @@
Reference
#########
.. automodule:: canaille.backends.models
:members:
:show-inheritance:
.. automodule:: canaille.core.models
:members:
:show-inheritance:
.. automodule:: canaille.oidc.models
:members:

18
poetry.lock generated
View file

@ -1517,20 +1517,20 @@ files = [
[[package]] [[package]]
name = "sphinx" name = "sphinx"
version = "6.2.1" version = "7.1.2"
description = "Python documentation generator" description = "Python documentation generator"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b"}, {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"},
{file = "sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912"}, {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"},
] ]
[package.dependencies] [package.dependencies]
alabaster = ">=0.7,<0.8" alabaster = ">=0.7,<0.8"
babel = ">=2.9" babel = ">=2.9"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
docutils = ">=0.18.1,<0.20" docutils = ">=0.18.1,<0.21"
imagesize = ">=1.3" imagesize = ">=1.3"
importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""}
Jinja2 = ">=3.0" Jinja2 = ">=3.0"
@ -1571,18 +1571,18 @@ tests = ["pytest (>=6.2.0)"]
[[package]] [[package]]
name = "sphinx-rtd-theme" name = "sphinx-rtd-theme"
version = "1.2.2" version = "1.3.0rc1"
description = "Read the Docs theme for Sphinx" description = "Read the Docs theme for Sphinx"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
files = [ files = [
{file = "sphinx_rtd_theme-1.2.2-py2.py3-none-any.whl", hash = "sha256:6a7e7d8af34eb8fc57d52a09c6b6b9c46ff44aea5951bc831eeb9245378f3689"}, {file = "sphinx_rtd_theme-1.3.0rc1-py2.py3-none-any.whl", hash = "sha256:ace3640f8951a93fd514fccd02071abac340c28fb0c907f180a7608c416e99a2"},
{file = "sphinx_rtd_theme-1.2.2.tar.gz", hash = "sha256:01c5c5a72e2d025bd23d1f06c59a4831b06e6ce6c01fdd5ebfe9986c0a880fc7"}, {file = "sphinx_rtd_theme-1.3.0rc1.tar.gz", hash = "sha256:3e321023694842feae0baed4f34004c4c925d812df9c93db49769c5887496d13"},
] ]
[package.dependencies] [package.dependencies]
docutils = "<0.19" docutils = "<0.19"
sphinx = ">=1.6,<7" sphinx = ">=1.6,<8"
sphinxcontrib-jquery = ">=4,<5" sphinxcontrib-jquery = ">=4,<5"
[package.extras] [package.extras]
@ -1870,4 +1870,4 @@ sentry = ["sentry-sdk"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "739e40a2b7ee9549652e6616743dbc2c9a25b7f56456c2e0a4691b1e25bf5094" content-hash = "ebdd4d05e69ac7e1c8b977d5d415867cfe8d0a4d09b02ac0f596b753b7a9a59c"

View file

@ -63,10 +63,10 @@ sentry-sdk = {version = "<2", optional=true, extras=["flask"]}
optional = true optional = true
[tool.poetry.group.doc.dependencies] [tool.poetry.group.doc.dependencies]
sphinx = "*" sphinx = "^7.0.0"
sphinx-rtd-theme = "*" sphinx-rtd-theme = "^1.2.0"
sphinx-issues = "*" sphinx-issues = "^3.0.0"
pygments-ldif = "==1.0.1" pygments-ldif = "^1.0.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
coverage = {version = "*", extras=["toml"]} coverage = {version = "*", extras=["toml"]}
@ -174,7 +174,7 @@ commands =
[testenv:doc] [testenv:doc]
commands = commands =
poetry install --only doc poetry install --with doc --without dev --extras oidc
poetry run sphinx-build doc build/sphinx/html poetry run sphinx-build doc build/sphinx/html
[testenv:coverage] [testenv:coverage]

View file

@ -1,4 +1,34 @@
import pytest
from canaille.app import models from canaille.app import models
from canaille.backends.models import Model
def test_required_methods(testclient):
with pytest.raises(NotImplementedError):
Model.query()
with pytest.raises(NotImplementedError):
Model.fuzzy("foobar")
with pytest.raises(NotImplementedError):
Model.get()
obj = Model()
with pytest.raises(NotImplementedError):
obj.identifier
with pytest.raises(NotImplementedError):
obj.save()
with pytest.raises(NotImplementedError):
obj.delete()
with pytest.raises(NotImplementedError):
obj.update()
with pytest.raises(NotImplementedError):
obj.reload()
def test_model_comparison(testclient, backend): def test_model_comparison(testclient, backend):

View file

@ -19,13 +19,7 @@ def test_required_methods(testclient):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
user.set_password("password") user.set_password("password")
with pytest.raises(NotImplementedError): Group()
user.identifier
group = Group()
with pytest.raises(NotImplementedError):
group.identifier
def test_user_get_from_login(testclient, user, backend): def test_user_get_from_login(testclient, user, backend):