forked from Github-Mirrors/canaille
doc: model documentation
This commit is contained in:
parent
b213610903
commit
553595c5ed
11 changed files with 213 additions and 53 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
93
canaille/backends/models.py
Normal file
93
canaille/backends/models.py
Normal 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()
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -39,6 +39,7 @@ Table of contents
|
|||
configuration
|
||||
troubleshooting
|
||||
contributing
|
||||
reference
|
||||
specifications
|
||||
changelog
|
||||
|
||||
|
|
13
doc/reference.rst
Normal file
13
doc/reference.rst
Normal 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
18
poetry.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue