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.filter
from canaille.backends.models import Model
from .backend import Backend
from .utils import ldap_to_python
@ -100,7 +101,7 @@ class LDAPObjectQuery:
return klass
class LDAPObject(metaclass=LDAPObjectMetaclass):
class LDAPObject(Model, metaclass=LDAPObjectMetaclass):
_object_class_by_name = None
_attribute_type_by_name = None
_may = None

View file

@ -5,6 +5,7 @@ import uuid
import canaille.core.models
import canaille.oidc.models
from canaille.app import models
from canaille.backends.models import Model
from flask import current_app
@ -13,10 +14,10 @@ def listify(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 = {}
attribute_indexes = {}
@ -51,11 +52,11 @@ class Model:
if not class_name:
class_name = cls.__name__
return Model.indexes.setdefault(class_name, {}).setdefault("id", {})
return MemoryModel.indexes.setdefault(class_name, {}).setdefault("id", {})
@classmethod
def attribute_index(cls, attribute="id", class_name=None):
return Model.attribute_indexes.setdefault(
return MemoryModel.attribute_indexes.setdefault(
class_name or cls.__name__, {}
).setdefault(attribute, {})
@ -172,7 +173,7 @@ class Model:
if other is None:
return False
if not isinstance(other, Model):
if not isinstance(other, MemoryModel):
return self == self.__class__.get(id=other)
return self.state == other.state
@ -187,7 +188,7 @@ class Model:
if name in self.model_attributes:
klass = getattr(models, self.model_attributes[name][0])
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
]
values = [value for value in values if value]
@ -222,7 +223,7 @@ class Model:
pass
class User(canaille.core.models.User, Model):
class User(canaille.core.models.User, MemoryModel):
attributes = [
"id",
"user_name",
@ -333,7 +334,7 @@ class User(canaille.core.models.User, Model):
super().save()
class Group(canaille.core.models.Group, Model):
class Group(canaille.core.models.Group, MemoryModel):
attributes = [
"id",
"display_name",
@ -351,7 +352,7 @@ class Group(canaille.core.models.Group, Model):
return getattr(self, self.identifier_attribute)
class Client(canaille.oidc.models.Client, Model):
class Client(canaille.oidc.models.Client, MemoryModel):
attributes = [
"id",
"description",
@ -406,7 +407,7 @@ class Client(canaille.oidc.models.Client, Model):
return getattr(self, self.identifier_attribute)
class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Model):
class AuthorizationCode(canaille.oidc.models.AuthorizationCode, MemoryModel):
attributes = [
"id",
"authorization_code_id",
@ -449,7 +450,7 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Model):
return getattr(self, self.identifier_attribute)
class Token(canaille.oidc.models.Token, Model):
class Token(canaille.oidc.models.Token, MemoryModel):
attributes = [
"id",
"token_id",
@ -488,7 +489,7 @@ class Token(canaille.oidc.models.Token, Model):
return getattr(self, self.identifier_attribute)
class Consent(canaille.oidc.models.Consent, Model):
class Consent(canaille.oidc.models.Consent, MemoryModel):
attributes = [
"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
from typing import Optional
from flask import g
from flask import session
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):
self.read = set()
self.write = set()
@ -12,10 +17,13 @@ class User:
super().__init__(*args, **kwargs)
@classmethod
def get_from_login(cls, login=None, **kwargs):
def get_from_login(cls, login=None, **kwargs) -> Optional["User"]:
raise NotImplementedError()
def login(self):
"""
Opens a session for the user.
"""
g.user = self
try:
previous = (
@ -29,6 +37,9 @@ class User:
@classmethod
def logout(self):
"""
Closes the user session.
"""
try:
session["user_id"].pop()
del g.user
@ -37,24 +48,25 @@ class User:
except (IndexError, KeyError):
pass
@property
def identifier(self):
def has_password(self) -> bool:
"""
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.
Checks wether a password has been set for the user.
"""
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()
def check_password(self, password):
def set_password(self, password: str):
"""
Sets a password for the user.
"""
raise NotImplementedError()
def set_password(self, password):
raise NotImplementedError()
def can_read(self, field):
def can_read(self, field: str):
return field in self.read | self.write
@property
@ -69,17 +81,16 @@ class User:
return super().__getattr__(name)
@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(
datetime.timezone.utc
)
class Group:
@property
def identifier(self):
"""
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.
User model, based on the `SCIM Group schema <https://datatracker.ietf.org/doc/html/rfc7643#section-4.2>`_
"""
raise NotImplementedError()

View file

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

View file

@ -39,6 +39,7 @@ Table of contents
configuration
troubleshooting
contributing
reference
specifications
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]]
name = "sphinx"
version = "6.2.1"
version = "7.1.2"
description = "Python documentation generator"
optional = false
python-versions = ">=3.8"
files = [
{file = "Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b"},
{file = "sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912"},
{file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"},
{file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"},
]
[package.dependencies]
alabaster = ">=0.7,<0.8"
babel = ">=2.9"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
docutils = ">=0.18.1,<0.20"
docutils = ">=0.18.1,<0.21"
imagesize = ">=1.3"
importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""}
Jinja2 = ">=3.0"
@ -1571,18 +1571,18 @@ tests = ["pytest (>=6.2.0)"]
[[package]]
name = "sphinx-rtd-theme"
version = "1.2.2"
version = "1.3.0rc1"
description = "Read the Docs theme for Sphinx"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
files = [
{file = "sphinx_rtd_theme-1.2.2-py2.py3-none-any.whl", hash = "sha256:6a7e7d8af34eb8fc57d52a09c6b6b9c46ff44aea5951bc831eeb9245378f3689"},
{file = "sphinx_rtd_theme-1.2.2.tar.gz", hash = "sha256:01c5c5a72e2d025bd23d1f06c59a4831b06e6ce6c01fdd5ebfe9986c0a880fc7"},
{file = "sphinx_rtd_theme-1.3.0rc1-py2.py3-none-any.whl", hash = "sha256:ace3640f8951a93fd514fccd02071abac340c28fb0c907f180a7608c416e99a2"},
{file = "sphinx_rtd_theme-1.3.0rc1.tar.gz", hash = "sha256:3e321023694842feae0baed4f34004c4c925d812df9c93db49769c5887496d13"},
]
[package.dependencies]
docutils = "<0.19"
sphinx = ">=1.6,<7"
sphinx = ">=1.6,<8"
sphinxcontrib-jquery = ">=4,<5"
[package.extras]
@ -1870,4 +1870,4 @@ sentry = ["sentry-sdk"]
[metadata]
lock-version = "2.0"
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
[tool.poetry.group.doc.dependencies]
sphinx = "*"
sphinx-rtd-theme = "*"
sphinx-issues = "*"
pygments-ldif = "==1.0.1"
sphinx = "^7.0.0"
sphinx-rtd-theme = "^1.2.0"
sphinx-issues = "^3.0.0"
pygments-ldif = "^1.0.1"
[tool.poetry.group.dev.dependencies]
coverage = {version = "*", extras=["toml"]}
@ -174,7 +174,7 @@ commands =
[testenv:doc]
commands =
poetry install --only doc
poetry install --with doc --without dev --extras oidc
poetry run sphinx-build doc build/sphinx/html
[testenv:coverage]

View file

@ -1,4 +1,34 @@
import pytest
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):

View file

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