Pārlūkot izejas kodu

Sync work progress.

Cixo Develop 2 mēneši atpakaļ
vecāks
revīzija
091f485f51

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 11 - 0
docs/html/source/apikey.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 11 - 0
docs/html/source/index.html


+ 3 - 0
docs/make-html.sh

@@ -0,0 +1,3 @@
+#!/bin/bash
+
+pdoc3 --html ../../cx-library-manager/source 

+ 1 - 0
requirements.txt

@@ -0,0 +1 @@
+tortoise-orm

+ 38 - 0
server_source/__init__.py

@@ -0,0 +1,38 @@
+from .apikey import apikey
+from .validators import validators
+from .password import password
+
+from .user import user
+from .user import user_proxy
+from .users_manager import users_manager
+
+from .proxy import proxy
+from .model import model
+from .field_generator import field_generator
+
+from .config import config_getter
+from .config import config_processor
+from .config import config_loader
+
+from .category import category
+from .category import category_proxy
+
+from .author import author
+from .author import author_proxy
+
+from .product_type import product_type
+from .product_type import product_type_proxy
+
+from .attachment import attachment 
+from .attachment import attachment_proxy
+
+from .item import item
+
+from .config_exceptions import config_exception
+from .config_exceptions import key_not_implemented
+from .config_exceptions import bad_type_loaded
+from .config_exceptions import invalid_config_processor
+from .config_exceptions import bad_key_type
+from .config_exceptions import bad_value_type
+from .config_exceptions import config_file_not_exists
+from .config_exceptions import config_file_not_readable

+ 162 - 0
server_source/apikey.py

@@ -0,0 +1,162 @@
+import os
+
+class apikey:
+    """
+    This class represents API key, and it is also creating new apikeys from 
+    device random numbers generator.
+
+    Methods
+    -------
+    get_raw_size() : int
+        Return size of bytes which was loaded from device random number 
+        generator.
+
+    get_size() : int
+        Return lenght of the API key as string.
+
+    get_prefix() : str
+        Return prefix of the API keys.
+
+    create() : apikey
+        Create new random API key.
+
+    export() : str
+        Export API key as string.
+    """
+
+    def __init__(self, content: str) -> None:
+        """
+        It recreate API key object from key, which was previously exported
+        to string.
+
+        Raises
+        ------
+        ValueError
+            When API key lenght is not correct.
+
+        Parameters
+        ----------
+        content : str
+            Preiviously exported API key string.
+        """
+        
+        if len(content) != self.get_size():
+            raise ValueError("API key lenght is not correct.")
+
+        self.__content = content
+    
+    @staticmethod
+    def get_raw_size() -> int:
+        """
+        It return lenght of the random bytes in the API key.
+
+        Returns
+        -------
+        int
+            Lenght of the random bytes in the API key.
+        """
+
+        return 128
+
+    @staticmethod
+    def get_size() -> int:
+        """
+        It return lenght of the API key string, with prefix and random bytes
+        converted to the hex string.
+
+        Returns
+        -------
+        int
+            Lenght of the API key string.
+        """
+
+        return len(apikey.get_prefix()) + apikey.get_raw_size() * 2
+
+    @staticmethod
+    def get_prefix() -> str:
+        """
+        It return API key prefix.
+
+        Returns
+        -------
+        str
+            Prefix for the API key.
+        """
+
+        return "cxtk_"
+
+    @staticmethod
+    def create() -> object:
+        """
+        It create new API key from random number generator device.
+
+        Returns
+        -------
+        apikey
+            New random API key.
+        """
+
+        raw = os.urandom(apikey.get_raw_size())
+        key = apikey.get_prefix() + raw.hex()
+
+        return apikey(key)
+
+    def export(self) -> str:
+        """
+        It export API key as string.
+
+        Returns
+        -------
+        str
+            API key string content.
+        """
+
+        return self.__content
+
+    def __repr__(self) -> str:
+        """
+        It return readable API key description (first 32 letters).
+
+        Returns
+        -------
+        str
+            Readable API key description.
+        """
+
+        return "API key: \"" + self.__content[0:32] + "...\""
+
+    def __str__(self) -> str:
+        """
+        It return API key string content.
+
+        Returns
+        -------
+        str
+            API key as string.
+        """
+
+        return self.__content
+
+    def __eq__(self, other) -> bool:
+        """
+        It check that API keys are same.
+
+        Returns
+        -------
+        bool  
+            True when API keys are same.
+        """
+
+        return self.__content == other.__content
+
+    def __ne__(self, other) -> bool:
+        """
+        It check that API keys are not same.
+
+        Returns 
+        -------
+        bool
+            True when API keys are not same.
+        """
+
+        return self.__content != other.__content

+ 30 - 0
server_source/attachment.py

@@ -0,0 +1,30 @@
+from .model import model
+from .proxy import proxy
+from .field_generator import field_generator
+from .constants import constants
+from .validators import validators
+
+class attachment(model):
+    id = field_generator.id()
+    name = field_generator.name()
+    description = field_generator.description()
+
+    def _validators(self) -> dict:
+        return {
+            "name": validators.name,
+            "description": validators.description
+        }
+
+class attachment_proxy(proxy):   
+    @classmethod
+    def create(cls, name: str) -> proxy:
+        return cls(attachment(
+            name = name,
+            description = constants.empty_text()
+        ))
+
+    def set_name(self, target: str) -> None:
+        self._target.name = target
+
+    def set_description(self, target: str) -> None:
+        self._target.description = target

+ 35 - 0
server_source/author.py

@@ -0,0 +1,35 @@
+from .model import model
+from .proxy import proxy
+from .field_generator import field_generator
+from .constants import constants
+from .validators import validators
+
+class author(model):
+    id = field_generator.id()
+    name = field_generator.name()
+    surname = field_generator.surname()
+    description = field_generator.description()
+
+    def _validators(self) -> dict:
+        return {
+            "name": validators.name,
+            "surname": validators.surname,
+            "description": validators.description
+        }
+
+class author_proxy(proxy):
+    @classmethod
+    def create(cls, name: str, surname: str) -> proxy:
+        return cls(author(
+            name = name,
+            surname = surname,
+            description = contants.empty_text()
+        ))
+
+    def set_name(self, name: str, surname: str) -> None:
+        self._target.name = name
+        self._target.surname = surname
+
+    def set_description(self, target: str) -> None:
+        self._target.description = target
+

+ 18 - 0
server_source/category.py

@@ -0,0 +1,18 @@
+from .model import model
+from .proxy import proxy
+from .single_set_proxy import single_set_proxy
+from .single_set_model import single_set_model
+from .field_generator import field_generator
+from .validators import validators
+
+class category(single_set_model):
+    content = field_generator.name()
+
+    def _single_validator(self) -> callable:
+        return validators.name
+
+class category_proxy(single_set_proxy):
+    @classmethod
+    def create(cls, content: str) -> proxy:
+        return cls._create(category, content)
+

+ 292 - 0
server_source/config.py

@@ -0,0 +1,292 @@
+import json
+import pathlib
+import functools
+
+from .config_exceptions import key_not_implemented
+from .config_exceptions import bad_type_loaded
+from .config_exceptions import invalid_config_processor
+from .config_exceptions import bad_key_type
+from .config_exceptions import bad_value_type
+from .config_exceptions import config_file_not_exists
+from .config_exceptions import config_file_not_readable
+
+class config_getter:
+    """
+    That is config getter, it is builded by config processor. It is read
+    only and is usefull to getting keys from config. When an key is not 
+    definied in configuration file, then default value is returned. Keys 
+    could be accessed by "get(key: str)" method, as attribute of instance
+    of that class or as dict keys.
+    """
+
+    def __init__(self, content: dict) -> None:
+        """
+        That create new instance of getter. It would be used only by config
+        processor.
+
+        Parameters
+        ----------
+        content : dict
+            Content of the config.
+        """
+
+        self.__content = dict(content)
+   
+    @functools.cache
+    def get(self, key: str) -> str | int | float | bool:
+        """
+        That function return given key from config.
+
+        Parameters
+        ----------
+        key : str
+            Name of the key to return.
+
+        Returns
+        -------
+        str | float | bool | int
+            Value of the key.
+
+        Raises
+        ------
+        key_not_implemented
+            When tried to load key which not exists.
+        """
+
+        if not key in self.__content:
+            raise key_not_implemented(key)
+
+        return self.__content[key]
+
+    def __getattr__(self, key: str) -> str | int | float | bool:
+        """
+        That is alias of "get(key: str)" function for getting keys as 
+        attributes of that class instance.
+        """
+
+        return self.get(key)
+
+    def __getitem__(self, key: str) -> str | int | float | bool:
+        """
+        That is alias of "get(key: str)" function for getting keys as
+        keys of dict.
+        """
+
+        return self.get(key)
+
+
+class config_processor:
+    """
+    That class is processor for configuration. It load defaults config 
+    options, then config loader addng keys from config file. That class
+    validating options, it key must exists in the defaults config, and
+    also type of default config value must be same as loaded value for 
+    that key.
+    """
+
+    def __init__(self) -> None:
+        """
+        It create new config processor. Content is set to default. Adding 
+        new keys replace defaults values by new ones from config loader.
+        """
+
+        self.__content = self.get_default_config()
+        self.__validate_defaults()
+
+    def __validate_defaults(self) -> None:
+        """
+        That function validate dictionary of defaults config. It could
+        find typical issues with configuration.
+
+        Raises
+        ------
+        bad_key_type
+            When key in the configuration dict is not string.
+
+        bad_value_type
+            When value for key in the configuration is in invalid type. Valid
+            types are str, int, float and bool.
+        """
+
+        for key, value in self.get_default_config().items():
+            if type(key) is not str:
+                raise bad_key_type(key)
+
+            if type(value) is str or type(value) is int:
+                continue
+            
+            if type(value) is float or type(value) is bool:
+                continue
+
+            raise bad_value_type(key, value)
+
+    def add(self, key: str, value: str | int | float | bool) -> None:
+        """
+        It load new key to config. Key must exists in the default config. It
+        replace value from default configuration by new value, loaded from 
+        configuration file.
+
+        Parameters
+        ----------
+        key : str
+            Name of the key from configuration.
+
+        value : str | int | float | bool
+            Value for that key.
+
+        Raises
+        ------
+        key_not_implemented
+            When key not exists in the default configuration.
+
+        bad_type_loaded
+            When type loaded from configuration is different from 
+            type in default config for that key.
+        """
+
+        if not key in self.get_default_config():  
+            raise key_not_implemented(key)
+
+        default = self.get_default_config()[key]
+        
+        if type(default) is not type(value):
+            raise bad_type_loaded(key, type(default), type(value))
+        
+        self.__content[key] = value
+
+    def result(self) -> config_getter:
+        """
+        It return new config getter. Config getter is object which make 
+        configuration read only.
+
+        Returns
+        -------
+        config_getter 
+            New config getter from configuration.
+        """
+
+        return config_getter(self.__content)
+
+    @functools.cache
+    def get_default_config(self) -> dict:
+        """
+        It return default configuration. It must be overwriten, it is 
+        abstract function.
+
+        Returns
+        -------
+        dict <str, str | int | float | bool>
+            Default configuration to load.
+        """
+
+        raise NotImplementedError()
+
+
+class config_loader:
+    def __new__(
+        cls, 
+        processor_class: type,
+        location: pathlib.Path | None = None
+    ) -> config_getter:
+        """
+        This load configuration from config file. It require configuration
+        processor which could handle options from file. Processor must be 
+        given as class, which is subclass of config_processor. That subclass
+        overwrite defaults config.
+
+        Parameters
+        ----------
+        processor_class : type
+            Subclass of config_processor with defaults config.
+        
+        location : pathlib.Path | None
+            Location of config file, or none. When give none, then 
+            default location from that class is used. To overwrite default
+            location create subclass of config_loader.
+
+        Raises
+        ------
+        invalid_config_processor
+            When config processor is not valid subclass.
+
+        config_file_not_exists
+            When config file does not exists.
+
+        config_file_not_readable
+            When config file contains syntax error, or file is
+            not readable because of filesystem error.
+
+        Returns
+        -------
+        config_getter
+            Config getter created by config processor, and loaded from config
+            file.
+        """
+
+        if type(processor_class) is not type:
+            raise invalid_config_processor(processor_class)
+
+        if not issubclass(processor_class, config_processor):
+            raise invalid_config_processor(processor_class)
+
+        processor = processor_class()
+        default = cls.get_default_location()
+
+        if location is None:
+            location = default
+
+        if not location.exists() or not location.is_file():
+            raise config_file_not_exists(location)
+
+        try:
+            return cls.__load(processor, location)
+
+        except Exception as error:
+            raise config_file_not_readable(error)
+
+    @classmethod
+    def __load(
+        cls, 
+        processor: config_processor, 
+        location: pathlib.Path
+    ) -> config_getter:
+        """
+        This function load config from configuration file. It open file, 
+        and adding all keys to the config processor. Finally it create 
+        config_getter to make config readonly.
+
+        Parameters
+        ----------
+        processor : config_processor
+            Config processor to work with.
+        
+        location : pathlib.Path
+            Location of the config file.
+
+        Returns
+        -------
+        config_getter
+            Result of config file.
+        """
+
+        with location.open() as handler:
+            for key, value in json.loads(handler.read()).items():
+                processor.add(key, value)
+        
+        return processor.result()
+
+    @classmethod
+    @functools.cache
+    def get_default_location(cls) -> pathlib.Path:
+        """
+        It is default location of config file. It is abstract function and
+        must being overwrite by subclass to make config loader useable.
+
+        Returns
+        -------
+        pathlib.Path
+            Path of the default config file.
+        """
+
+        raise NotImplementedError()
+

+ 150 - 0
server_source/config_exceptions.py

@@ -0,0 +1,150 @@
+import pathlib
+
+class config_exception(Exception):
+    """
+    This exception is base class for other exceptions in configuration.
+    """
+
+    pass
+
+
+class key_not_implemented(config_exception):
+    """ 
+    This exception is raised when key is not in defaults config, but found
+    in the config file.
+    """
+
+    def __init__(self, key: str) -> None:
+        """
+        Parameters
+        ----------
+        key : str
+            Name of the key, which is not implemented.
+        """
+
+        super().__init__("Key \"" + key + "\" is not implemented.")
+
+
+class bad_type_loaded(config_exception):
+    """
+    This exception is raised when key loaded from config file is in invalid
+    type. Valid type is same as type as type of value that key from defaults
+    config.
+    """
+
+    def __init__(self, key: str, required: type, own: type) -> None:
+        """
+        Parameters
+        ----------
+        key : str
+            Name of the key.
+        
+        required : type
+            Required type for that key.
+
+        own : type
+            Type of that key from config file.
+        """
+        
+        required = required.__name__
+        own = own.__name__
+
+        error = "Value for key \"" + key + "\" must be an " + required
+        error = error + " but is " + own + "."
+
+        super().__init__(error)
+
+
+class invalid_config_processor(config_exception):
+    """
+    That exception is raised when config preprocessor class, given in 
+    config_loader builder is not valid config_loader child class.
+    """
+
+    def __init__(self, which: any) -> None:
+        """
+        Parameters
+        ----------
+        which : any
+            Class given as config preprocessor.
+        """
+
+        super().__init__("Invalid config processor: " + str(which) + ".")
+
+
+class bad_key_type(config_exception):
+    """
+    That exception is raised when name of the key is not an string.
+    """
+
+    def __init__(self, key: any) -> None:
+        """
+        Parameters
+        ----------
+        key : any
+            Key, which is not an string.
+        """
+
+        super().__init__("Key must be string, but is \"" + repr(key) + "\".")
+
+
+class bad_value_type(config_exception):
+    """
+    This exception is raised when key in defaults config is not in valid.
+    Valid type is string, number (int or float) and boolean. Value can not
+    being object, list or other complex types.
+    """
+
+    def __init__(self, key: str, value: any) -> None:
+        """
+        Parameters
+        ----------
+        key : str
+            Name of the invalid key.
+        
+        value : any
+            Value in invalid type.
+        """
+
+        error = "Key \"" + key + "\" has invalid type of value. Int, "
+        error = error + "string, float or bool is required, but \""
+        error = error + repr(value) + "\" found."
+
+        super().__init__(error)
+
+
+class config_file_not_exists(config_exception):
+    """
+    That exception is raised when config file, which config_loader tried
+    to load, does not exists.
+    """
+
+    def __init__(self, file: pathlib.Path) -> None:
+        """
+        Parameters
+        ----------
+        file : pathlib.Path
+            Config file which not exists.
+        """
+
+        super().__init__("Config file " + str(file) + "not exists.")
+
+
+class config_file_not_readable(config_exception):
+    """
+    That exception is raised when config file is not readable, because could
+    not being opened or have syntax error.
+    """
+
+    def __init__(self, error: Exception) -> None:
+        """
+        Parameters
+        ----------
+        error : Exception 
+            Exception which was raised by json loader or file handler.
+        """
+
+        super().__init__("Config file is not readable: " + str(error) + ".")
+
+
+

+ 14 - 0
server_source/constants.py

@@ -0,0 +1,14 @@
+class constants:
+    @staticmethod
+    def empty_text() -> str:
+        return str()
+
+    @staticmethod
+    def related_name(
+        target: str,
+        field: str | None = None
+    ) -> str:
+        if field is None:
+            return target + "_prefetch"
+
+        return target + "_field_ " + field + "_prefetch"

+ 40 - 0
server_source/exceptions.py

@@ -0,0 +1,40 @@
+class nick_in_use(Exception):
+    def __init__(self, nick: str) -> None:  
+        super().__init__("Nick \"" + nick + "\" already in use.")
+
+class user_is_only_admin(Exception):    
+    def __init__(self) -> None:
+        super().__init__("This user is only admin, can not being removed.")
+
+class invalid_nick_syntax(Exception):
+    def __init__(self, nick: str, error: Exception) -> None:
+        comment = "Nick \"" + nick + "\" has invalid syntax."
+        comment = comment + "\nError: " + str(error)
+
+        super().__init__(comment)
+
+class user_is_not_admin(Exception): 
+    def __init__(self): 
+        super().__init__("To do that action user must be admin.")
+
+class invalid_password_syntax(Exception):
+    def __init__(self, password: str, error: Exception) -> None:
+        comment = "Password \"" + password + "\" has invalid syntax."
+        comment = comment + "\nError: " + str(error)
+
+        super().__init__(comment)
+
+class invalid_apikey_syntax(Exception):
+    def __init__(self, apikey: str, error: Exception) -> None:
+        comment = "Api Key \"" + apikey + "\" has invalid syntax."
+        comment = comment + "\nError: " + str(error)
+
+        super().__init__(comment)
+
+class nick_not_exists(Exception):   
+    def __init__(self, nick: str) -> None:
+        super().__init__("User with nick \"" + nick + "\" not exists.")
+
+class apikey_not_exists(Exception):
+    def __init__(self, apikey: str) -> None:    
+        super().__init__("Api Key \"" + apikey + "\" does not exists.")

+ 95 - 0
server_source/field_generator.py

@@ -0,0 +1,95 @@
+import tortoise.fields
+
+from .model import model
+from .constants import constants
+
+class field_generator:
+    @staticmethod
+    def id() -> tortoise.fields.IntField:
+        return tortoise.fields.IntField(primary_key = True)
+
+    @staticmethod
+    def name() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 30)
+
+    @staticmethod
+    def password() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 256)
+
+    @staticmethod
+    def surname() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 30)
+
+    @staticmethod
+    def nick() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 20)
+
+    @staticmethod
+    def description() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 4096)
+        
+    @staticmethod
+    def phone_number() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 15)
+
+    @staticmethod
+    def barcode() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 13)
+
+    @staticmethod
+    def email() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 30)
+
+    @staticmethod
+    def apikey() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 280)
+
+    @staticmethod
+    def secret() -> tortoise.fields.TextField:
+        return tortoise.fields.TextField(max_lenght = 256)
+
+    @staticmethod
+    def permissions() -> tortoise.fields.BooleanField:
+        return tortoise.fields.BooleanField()
+
+    @staticmethod
+    def stock() -> tortoise.fields.IntField:
+        return tortoise.fields.IntField
+
+    @staticmethod
+    def __connected_names(target: type, field: str | None) -> [str, str]:
+        if not issubclass(target, model):
+            raise TypeError("Foreign key target is not child of model.")
+
+        name = target.__name__
+        model_name = model.Meta.app + "." + name
+        related_name = constants.related_name(name, field)
+
+        return model_name, related_name
+
+    @classmethod
+    def connected_many(
+        cls, 
+        target: type, 
+        field: str | None = None
+    ) -> tortoise.fields.ManyToManyField:
+        model, related = cls.__connected_names(target, field)
+
+        return tortoise.fields.ManyToManyField(
+            model,
+            related_name = related
+        )
+
+    @classmethod
+    def connected_single(
+        cls, 
+        target: type,
+        field: str | None = None
+    ) -> tortoise.fields.ForeignKeyField:
+        model, related = cls.__connected_names(target, field)
+
+        return tortoise.fields.ForeignKeyField(
+            model, 
+            related_name = related
+        )
+

+ 28 - 0
server_source/item.py

@@ -0,0 +1,28 @@
+from .model import model
+from .field_generator import field_generator
+from .category import category
+from .author import author
+from .product_type import product_type
+from .proxy import proxy
+from .attachment import attachment
+from .validators import validators
+
+class item(model):
+    id = field_generator.id()
+    name = field_generator.name()
+    description = field_generator.description()
+    on_stock = field_generator.stock()
+    barcode = field_generator.barcode()
+    category = field_generator.connected_single(category)
+    author = field_generator.connected_single(author)
+    product_type = field_generator.connected_single(product_type)
+    cover = field_generator.connected_single(attachment, "cover")
+    attachments = field_generator.connected_many(attachment, "attachments")
+
+    def _validators(self) -> dict:
+        return {
+            "name": validators.name,
+            "description": validators.description,
+            "barcode": validators.barcode
+        }
+

+ 5 - 0
server_source/manager.py

@@ -0,0 +1,5 @@
+from .model import model
+
+class manager:
+    def _get_target_model(self) -> type:
+        raise NotImplementedError()

+ 39 - 0
server_source/model.py

@@ -0,0 +1,39 @@
+import tortoise.models
+
+from .field_generator import field_generator
+
+class model(tortoise.models.Model):
+    id = field_generator.primary_key()
+
+    def _validators(self) -> dict:
+        return dict()
+
+    def __validate_item(self, validator: callable, key: str) -> None:
+        try:
+            if validator(self.__get(key)) == self.__get(key):
+                return
+
+            raise ValueError("Validated value is not same.")
+
+        except Exception as error:
+            value = self.__get(key)
+            model_name = type(self).__name__
+            
+            info = "Model \"" + model_name + "\" contain invalid "
+            info = info + "data in field \"" + key + "\". Content of "
+            info = info + "field is \"" + value + "\". Found error \""
+            info = info + str(error) + "\"."
+
+            raise ValueError(info)
+
+    def __get(self, key: str) -> any:
+        return self.__getattribute__(key)
+
+    def validate(self) -> object:
+        for key, validator in self._validators().items():
+            self.__validate_item(validator, key)
+        
+        return self
+
+    class Meta:
+        app = "class"

+ 292 - 0
server_source/password.py

@@ -0,0 +1,292 @@
+import os
+import hashlib
+import asyncio
+
+class password():
+    """
+    This class represents password in the system, it is also used to hash
+    password, to protect it agains attacks.
+
+    Methods
+    -------
+    get_salt_length() : int
+        Return size in bytes of salt for passwords.
+
+    get_algorithm() : str
+        Return name of algorithm used to hash password.
+
+    async comare(password: str) : bool
+        Check that password given as parameter is same as hashed password
+        stored by password object which that function run on.
+
+    result() : str
+        Get hashed result password, to store it in database.
+
+    async from_plain_text(password: str) : password
+        It create new password object from given plain password. It get 
+        random salt, and create new hash.
+
+    async from_hash(hashed: str) : password
+        It create new password object from already hashed password, it is
+        usefull to compare password with plain text password.
+    """
+
+    def __init__(self, hashed: str) -> None:
+        """
+        It create new password from hashed content, it would not being 
+        called outside this class.
+
+        Parameters
+        ----------
+        hashed : str
+            Already hashed password
+        """
+
+        self.__hash = hashed
+
+    @staticmethod
+    def get_salt_length() -> int:
+        """
+        It return salt length for the password in the bytes. To add
+        salt to password it must be string, and it is converted to hex 
+        values. That mean, size of string salt is twice of salt lenght
+        in bytes.
+
+        Returns
+        -------
+        int
+            Lenght of the passwords salt.
+        """
+
+        return 12
+
+    @staticmethod
+    def get_algorithm() -> str:
+        """
+        That return name of the algorithm which would be used to hash 
+        password.
+
+        Returns
+        -------
+        str
+            Name of the password which would be used to hash password.
+        """
+
+        return "sha512"
+
+    @staticmethod
+    def get_iterations() -> int:
+        """
+        That return count of the iterations of the algorithm. 
+
+        Returns
+        -------
+        int
+            Count of the iterations of the algorithm.
+        """
+
+        return 50000
+
+    @staticmethod
+    async def __generate_hash(
+        content: str, 
+        salt: str,
+        iterations: int
+    ) -> str:
+        """
+        That function generate hash of the password from the password
+        and its salt. That function is async, because hashing algorithm
+        take a lot of time. 
+
+        Parameters
+        ----------
+        content : str
+            Plain password.
+
+        salt : str
+            Salt to use with password.
+
+        iterations : int
+            Count of iterations for hashing algorithm.
+
+        Returns 
+        -------
+        str
+            Hashed password.
+        """
+
+        content = content.encode("UTF-8")
+        salt = salt.encode("UTF-8")
+
+        hashed = await asyncio.to_thread(
+            hashlib.pbkdf2_hmac,
+            password.get_algorithm(),
+            content,
+            salt,
+            iterations
+        )
+
+        hashed = hashed.hex()
+        salt = salt.decode("UTF-8")
+        iterations = str(iterations)
+
+        return hashed + ":" + salt + ":" + iterations
+    
+    def __get_salt(self) -> str:
+        """
+        That function return salt used to hash password from that hash.
+        It could raise error, when hash is not correct.
+
+        Raises
+        ------
+        RuntimeError
+            When salt syntax is not correct.
+
+        Returns
+        -------
+        str
+            Salt of the hash.
+        """
+
+        try:
+            _, salt, _ = self.__hash.split(":")
+            return salt
+
+        except:
+            raise RuntimeError("Hash is incorrect.")
+
+    def __get_own_iterations(self) -> int:
+        """
+        That return count of iterations stored in the hash. It is required, 
+        to make old passwords work after change of the password iterations 
+        in the app.
+
+        Raises
+        ------
+        RuntimeError
+            When hash syntax is not correct.
+
+        Returns
+        -------
+        int
+            Count of iterations from hash.
+        """
+
+        try:
+            _, _, iterations = self.__hash.split(":")
+            return int(iterations)
+
+        except:
+            raise RuntimeError("Hash is incorrect.")
+
+    async def compare(self, target: str) -> bool:
+        """
+        That function compare given plain text password with already 
+        hashed password. It is async, because it must calculate hash of
+        the given plain text with hashed password salt, to compare result
+        hashes.
+
+        Parameters
+        ----------
+        str
+            Plain text to compare hashed password with.
+
+        Returns
+        -------
+        bool
+            True when passwords are same, False when not.
+        """
+
+        salt = self.__get_salt()
+        iterations = self.__get_own_iterations()
+
+        hashed_target = await self.__generate_hash(
+            target, 
+            salt,
+            iterations
+        )
+
+        return self.__hash == hashed_target
+    
+    def result(self) -> str:
+        """
+        It return hash of the password.
+
+        Returns
+        -------
+        str
+            Hash of the password.
+        """
+
+        return self.__hash
+
+    def __repr__(self) -> str:
+        """
+        It return part of the password hash, to make it readable while 
+        debugging.
+
+        Returns
+        -------
+        str
+            Part of the hashed password.
+        """
+
+        return "Password: \"" + self.__hash[0:20] + "\"." 
+
+    def __str__(self) -> str:
+        """
+        Return password hash.
+
+        Returns
+        -------
+        str
+            Password hash.
+        """
+
+        return self.__hash
+
+    async def from_plain_text(content: str) -> object:
+        """
+        It create new hashed password from plain text. It is async, 
+        because hashing process take a lot of time.
+
+        Parameters
+        ----------
+        content : str
+            Plain text password to make hash from.
+
+        Returns
+        -------
+        password
+            New password object with that password as hash.
+        """
+
+        salt = os.urandom(password.get_salt_length())
+        salt = salt.hex()
+        iterations = password.get_iterations()
+
+        hashed = await password.__generate_hash(
+            content, 
+            salt, 
+            iterations
+        )
+
+        return password(hashed)
+
+    async def from_hash(hashed: str) -> object:
+        """
+        It create new password object from already hashed password content
+        It is async, to make it similar to from_plain_text.
+
+        Parameters
+        ----------
+        hashed : str
+            Already hashed password.
+
+        Returns
+        -------
+        password
+            New password object from given hash.
+        """
+
+        return password(hashed)

+ 24 - 0
server_source/product_type.py

@@ -0,0 +1,24 @@
+from .proxy import proxy
+from .model import model
+from .field_generator import field_generator
+from .validators import validators
+
+class product_type(model):
+    id = field_generator.id()
+    name = field_generator.name()
+
+    def _validators(self) -> dict:
+        return {
+            "name": validators.name
+        }
+
+class product_type_proxy(proxy):
+    @classmethod
+    def create(cls, name: str) -> proxy:    
+        return cls(product_type(
+            name = name
+        ))
+
+    def set_name(self, target: str) -> None:
+        self._target.name = target
+

+ 10 - 0
server_source/proxy.py

@@ -0,0 +1,10 @@
+class proxy():
+    def __init__(self, target: object) -> None:
+        self.__target = target
+
+    @property
+    def _target(self) -> object:
+        return self.__target
+
+    def result(self) -> object:
+        return self.__target.validate()

+ 13 - 0
server_source/single_set_manager.py

@@ -0,0 +1,13 @@
+from .manager import manager
+from .single_set_model import single_set_model
+
+class single_set_manager(manager):
+    def add(self, target: str) -> single_set_model:
+        target_model = self._get_target_model()
+        
+        result = await target_model \
+        .filter(target_model.content = target) \
+        .first()
+
+        if result is None:
+            result = self._create(target)

+ 24 - 0
server_source/single_set_model.py

@@ -0,0 +1,24 @@
+from .model import model
+
+class single_set_model(model):
+    content = NotImplemented
+
+    def _single_validator(self) -> callable:
+        raise NotImplementedError()
+
+    def _validators(self) -> dict:
+        return {
+            "content": self._single_validator()
+        }
+
+    def __new__(cls, *args, **kwargs) -> object:
+        if cls.content == NotImplemented:
+            raise NotImplementedError("Attribute content must be field.")
+
+        super().__new__(*args, **kwargs)
+
+    def __repr__(self) -> str:
+        return type(self).__name__ + ": \"" + str(self) + "\""
+
+    def __str__(self) -> str:
+        return self.content

+ 23 - 0
server_source/single_set_proxy.py

@@ -0,0 +1,23 @@
+from .proxy import proxy
+from .single_set_model import single_set_model
+
+class single_set_proxy(proxy):
+    def set(self, target: str) -> None:
+        self._target.content = target
+
+    def get(self) -> str:
+        return self._target.content
+
+    @classmethod
+    def _create(cls, target: type, content: str) -> proxy:
+        if type(target) is not type:
+            raise TypeError("Target must be an class.")
+
+        if not issubclass(target, single_set_model):
+            raise TypeError("Target must be subclass of single_set_model.")
+
+            return cls(target(content = content))
+    
+    @classmethod    
+    def create(cls, content: str) -> proxy:
+        raise NotImplementedError()

+ 56 - 0
server_source/user.py

@@ -0,0 +1,56 @@
+import asyncio
+
+from .proxy import proxy
+from .apikey import apikey
+from .password import password
+from .field_generator import field_generator
+from .model import model
+from .validators import validators
+
+class user(model):
+    id = field_generator.id()
+    nick = field_generator.nick()
+    secret = field_generator.secret()
+    apikey = field_generator.apikey()
+    is_admin = field_generator.permissions()
+
+    def _validators(self) -> dict:
+        return {
+            "nick": validators.nick,
+            "apikey": validators.apikey
+        }       
+
+class user_proxy(proxy):
+    @classmethod
+    async def create(cls, nick: str, plain_password: str) -> proxy:
+        secret = await password.from_plain_text(plain_password)
+        key = apikey.create()
+
+        return cls(user(
+            nick = nick,
+            secret = secret.result(),
+            apikey = key.export(),
+            is_admin = False
+        ))
+
+    def is_admin(self) -> bool:
+        return self._target.is_admin
+
+    def make_admin(self) -> None:
+        self._target.is_admin = True
+   
+    def make_standard_user(self) -> None:
+        self._target.is_admin = False
+
+    async def set_password(self, plain_password: str) -> None:
+        secret = await password.from_plain_text(plain_password)
+        self._target.secret = secret.result()
+
+    async def compare_password(self, target: str) -> bool:
+        secret = await password.from_hash(self._target.secret)
+        result = await secret.compare(target)
+
+        return result
+
+    def drop_sessions(self) -> None:
+        self._target.apikey = apikey.create().export()

+ 240 - 0
server_source/users_manager.py

@@ -0,0 +1,240 @@
+import asyncio
+import tortoise.transactions
+
+from .user import user
+from .user import user_proxy
+from .validators import validators
+from .exceptions import nick_in_use
+from .exceptions import nick_not_exists
+from .exceptions import user_is_not_admin
+from .exceptions import apikey_not_exists
+from .exceptions import invalid_nick_syntax
+from .exceptions import invalid_apikey_syntax
+from .exceptions import invalid_password_syntax
+from .exceptions import user_is_only_admin
+
+class users_manager:
+    @classmethod
+    async def api_register(
+        cls, 
+        apikey: str, 
+        nick: str, 
+        password: str
+    ) -> [user, user]:
+        requester = await cls.require_by_apikey(apikey)
+
+        if not requester.is_admin:
+            raise user_is_not_admin()
+
+        return await cls.register(nick, password), requester
+
+    @classmethod
+    async def register(cls, nick: str, password: str) -> user:
+        if await cls.is_nick_in_use(nick):
+            raise nick_in_use(nick)
+
+        try:
+            nick = validators.nick(nick)
+        except Exception as error:
+            raise invalid_nick_syntax(nick, error)
+
+        async with tortoise.transactions.in_transaction():
+            proxy = await user_proxy.create(nick, password)
+
+            while await cls.is_apikey_in_use(proxy.result().apikey):
+                proxy.drop_sessions()
+
+            user = proxy.result()
+            
+            await user.save()
+            return user
+
+    @classmethod
+    async def set_privileges_process(
+        cls, 
+        apikey: str, 
+        nick: str, 
+        privileges: bool
+    ) -> [user, user]:
+        requester = await cls.get_by_apikey(apikey)
+
+        if requester.nick == nick:
+            raise can_not_change_own_privileges()
+
+        if not requester.is_admin:
+            raise user_is_not_admin()
+
+        return requester, await cls.set_privileges(nick, privileges)
+        
+    @classmethod
+    async def set_privileges(cls, nick: str, privileges: bool) -> user:
+        async with tortoise.transactions.in_transaction():
+            target = await cls.require_by_nick(nick)
+            admins_count = await cls.count_admins()
+            
+            if admins_count == 1 and target.is_admin:
+                raise user_is_only_admin
+
+            target.is_admin = privileges
+            
+            await target.save()
+            return target
+
+    @classmethod
+    async def remove(cls, nick: str) -> None:
+        async with tortoise.transactions.in_transaction():
+            target = await cls.require_by_nick(nick)
+            admins_count = await cls.count_admins()
+            
+            if target.is_admin and admins_count == 1:
+                raise user_is_only_admin()
+
+            await target.delete()
+
+    @classmethod
+    async def remove_process(cls, apikey: str, nick: str) -> None:
+        requester = await cls.require_by_apikey(apikey)
+        
+        if requester.nick == nick:
+            return await cls.remove(nick)
+
+        if requester.is_admin is False:
+            raise user_is_not_admin()
+
+        await cls.remove(nick)
+
+    @classmethod
+    async def count_admins(cls) -> int:
+        return await user.filter(is_admin = True).count()
+
+    @classmethod 
+    async def login(cls, nick: str, password: str) -> user | None:
+        try:
+            nick = validators.nick(nick)
+        except Exception as error:
+            raise invalid_nick_syntax(nick, error)
+
+        async with tortoise.transactions.in_transaction():
+            result = await user.filter(nick = nick).first()
+            proxy = user_proxy(result)
+
+            if await proxy.compare_password(password):
+                return result
+
+            return None
+
+    @classmethod 
+    async def change_own_password(cls, apikey: str, password: str) -> user:
+        target = await cls.require_by_apikey(apikey)
+        return await cls.change_password(target, password)
+
+    @classmethod
+    async def change_other_password(
+        cls,
+        apikey: str,
+        nick: str,
+        password: str
+    ) -> [user, user]:
+        requester = await cls.require_by_apikey(apikey)
+        target = await cls.require_by_nick(nick)
+
+        if not requester.is_admin:
+            raise user_is_not_admin()
+
+        return requester, await cls.change_password(target, password)
+
+    @classmethod
+    async def drop_own_sessions(cls, apikey: str) -> user:
+        target = await cls.require_by_apikey(apikey)
+        return await cls.drop_sessions(target)
+
+    @classmethod
+    async def drop_other_sessions(
+        cls, 
+        apikey: str, 
+        nick: str
+    ) -> [user, user]:
+        requester = await cls.require_by_apikey(apikey)
+        target = await cls.require_by_nick(nick)
+
+        if not requester.is_admin:  
+            raise user_is_not_admin()
+
+        return requester, await cls.drop_sessions(target)
+
+
+    @classmethod
+    async def change_password(cls, target: user, password: str) -> user:
+        try:
+            password = validatord.password(password)
+        except Exception as error:
+            raise invalid_password_syntax(password, error)
+
+        proxy = user_proxy(target)
+        await proxy.set_password(password)
+        
+        async with tortoise.transactions.in_transaction():
+            result = proxy.result()
+            await result.save()
+
+            return result
+
+    @classmethod
+    async def drop_sessions(cls, target: user) -> user:
+        async with tortoise.transactions.in_transaction():
+            proxy = user_proxy(target)
+            proxy.drop_sessions(self)
+            
+            while await cls.is_apikey_in_use(proxy.result().apikey):
+                proxy.drop_sessions(self)
+            
+            result = proxy.result()
+            await result.save()
+
+            return result
+
+    @classmethod 
+    async def get_by_apikey(cls, apikey: str) -> user | None:
+        try:
+            apikey = validators.apikey(apikey)
+        except Exception as error:
+            raise invalid_apikey_syntax(apikey, error)
+
+        async with tortoise.transactions.in_transaction():
+            return await user.filter(apikey = apikey).first()
+
+    @classmethod
+    async def is_apikey_in_use(cls, apikey: str) -> bool:
+        return (await cls.get_by_apikey(apikey)) is not None
+
+    @classmethod
+    async def require_by_apikey(cls, apikey: str) -> user:
+        target = await cls.get_by_apikey(apikey)
+
+        if target is None:
+            raise apikey_not_exists(apikey)
+
+        return target
+
+    @classmethod
+    async def get_by_nick(cls, nick: str) -> user | None:
+        try:
+            nick = validators.nick(nick)
+        except Exception as error:
+            raise invalid_nick_syntax(nick, error)
+
+        async with tortoise.transactions.in_transaction():
+            return await user.filter(nick = nick).first()
+           
+    @classmethod
+    async def is_nick_in_use(cls, nick: str) -> bool:
+        return (await cls.get_by_nick(nick)) is not None 
+
+    @classmethod
+    async def require_by_nick(cls, nick: str) -> user:
+        target = await cls.get_by_nick(nick)
+
+        if target is None:  
+            raise nick_not_exists(nick)
+
+        return target

+ 116 - 0
server_source/validators.py

@@ -0,0 +1,116 @@
+import re 
+
+from .apikey import apikey
+from .validators_base import validators_base
+
+class validators(validators_base):
+    @staticmethod
+    def password(content: str) -> str:
+        content = validators._validate_length(content, "password", 8, 64)
+
+        if re.search("\d", content) is None:
+            raise ValueError("Password must have one or more digits.")
+
+        if re.search("\s", content) is not None:
+            raise ValueError("Password can not contain whitespace chars.")
+
+        return content
+    
+    @staticmethod
+    def nick(content: str) -> str:
+        content = validators._validate_lenght(content, "nick", 4, 20)
+        content = validators._validate_generic_name(content, "nick")
+
+        return content
+
+    @staticmethod
+    def name(content: str) -> str:
+        content = validators._validate_lenght(content, "name", 4, 30)
+        content = validators._validate_generic_name(content, "name")
+
+        return content
+
+    @staticmethod
+    def surname(content: str) -> str:
+        content = validators._validate_lenght(content, "surnick", 4, 30)
+        content = validators._validate_generic_name(content, "surnick")
+
+        return content
+
+    @staticmethod
+    def description(content: str) -> str:
+        return validators._validate_lenght(content, "description", 0, 4096)
+   
+    @staticmethod
+    def phone_number(content: str) -> str:
+        if len(content) == 0:
+            raise ValueError("Phone number can not being empty.")
+        
+        content = content.replace("-", "")
+        country_code = None
+
+        if content[0] == "+":
+            parts = content.split(" ")
+            
+            if len(parts) < 2:
+                raise ValueError("Invalid phone number format.")
+
+            country_code = parts.pop(0)[1:]
+            content = str().join(parts)
+
+            if not country_code.isdigit():
+                raise ValueError("Country code must contain only digits.")
+
+            if len(countr_code) < 1 or len(country_code) > 3:
+                raise ValueError("Country code must have <1;3> chars.")
+            
+        content = content.replace(" ", "")
+
+        if len(content) < 6:
+            raise ValueError("Phone number is too short.")
+
+        if country_code is None and len(content) > 15:
+            raise ValueError("Phone number is too long.")
+
+        if country_code is not None and len(content + country_code) > 13:
+            raise ValueError("Phone number is too long.")
+        
+        if country_code is not None:
+            content = "+" + country_code + " " + content
+
+        return content 
+
+    @staticmethod
+    def barcode(content: str) -> str:
+        if not content.isdigit():
+            raise ValueError("Barcode must be a number.")
+
+        if len(content) != 12 and len(content) != 13:
+            raise ValueError("Barcode is not property EAN-12 or EAN-13.")
+
+        return content
+
+    @staticmethod
+    def email(content: str) -> str:
+        content = content.replace(" ", "")
+
+        if len(content) > 30:
+            raise ValueError("Email is too long.")
+
+        calc = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b"
+
+        if re.fullmatch(calc, content) is None:
+            raise ValueError("E-mail is not valid.")
+
+        return content
+    
+    def apikey(content: str) -> str:
+        if len(content) != apikey.get_size():
+            raise ValueError("API key lenght is not valid.")
+
+        if content[0:len(apikey.get_prefix())] != apikey.get_prefix():
+            raise ValueError("API key is not valid.")
+
+        return content
+        
+      

+ 34 - 0
server_source/validators_base.py

@@ -0,0 +1,34 @@
+import re
+
+class validators_base:
+    @staticmethod
+    def _validate_generic_name(content: str, name: str = "it") -> str:
+        if re.search("\s", content) is not None:
+            raise ValueError(
+                name.title() + " can not contain whitespace chars."
+            )
+
+        if re.search("\W", content) is not None:
+            raise ValueError(
+                name.title() + " can contain only _ and alphanumeric chars."
+            )
+
+        return content 
+
+    @staticmethod 
+    def _validate_lenght(
+        content: str, 
+        name: str,
+        minimum: int, 
+        maximum: int | None
+    ) -> str:
+        if len(content) < minimum:
+            raise ValueError(name.title() + " is too short.")
+
+        if maximum is not None and len(content) > maximum:
+            raise ValueError(name.title() + " is too long.")
+
+        return content
+
+
+

+ 10 - 0
server_tests/000-default.py

@@ -0,0 +1,10 @@
+import sys
+import pathlib
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test

+ 28 - 0
server_tests/001-apikey.py

@@ -0,0 +1,28 @@
+import sys
+import pathlib
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test
+
+print("Creating 2 API keys...")
+
+sample_1 = source.apikey.create()
+sample_2 = source.apikey.create()
+
+print(repr(sample_1))
+print(repr(sample_2))
+print()
+
+print("Exporting and reimporting...")
+
+sample_1_key = sample_1.export()
+sample_1_copy = source.apikey(sample_1_key)
+print()
+
+print("Testing imported content...")
+test(sample_1, sample_1_copy)

+ 24 - 0
server_tests/002-validators.py

@@ -0,0 +1,24 @@
+import sys
+import pathlib
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test, test_error
+
+test(source.validators.email("[email protected]"), "[email protected]")
+
+test_error(
+    ValueError,
+    source.validators.email,
+    "test@example"
+)
+
+test_error(
+    ValueError,
+    source.validators.nick,
+    "a"
+)

+ 56 - 0
server_tests/003-password.py

@@ -0,0 +1,56 @@
+import sys
+import pathlib
+import asyncio
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test
+
+async def main():
+    print("Generating password...")
+    password = await source.password.from_plain_text("Sample")
+    print(repr(password))
+    print("Lenght: " + str(len(password.result())))
+    print()
+
+    print("Checking password...")
+    test(await password.compare("Sample"), True)
+    test(await password.compare("Samples"), False)
+    print()
+
+    print("Generating other password...")
+    second_password = await source.password.from_plain_text("other")
+    print(repr(second_password))
+    print()
+
+    print("Checking password...")
+    test(await second_password.compare("other"), True)
+    test(await second_password.compare("other_fail"), False)
+    print()
+
+    print("Generating other password with same content other salt...")
+    variant = await source.password.from_plain_text("Sample")
+    print(repr(variant))
+    print()
+
+    print("Checing it...")
+    test(await variant.compare("Sample"), True)
+    test(await password.compare("Sample"), True)
+    print()
+
+    print("Regenerating password from hash...")
+    restore = await source.password.from_hash(password.result())
+    print("Old: " + repr(password))
+    print("Restored: " + repr(restore))
+    print()
+
+    print("Validating...")
+    test(await restore.compare("Sample"), True)
+    test(await restore.compare("Sampled"), False)
+    print()
+
+asyncio.run(main())

+ 32 - 0
server_tests/004-async-password.py

@@ -0,0 +1,32 @@
+import asyncio
+import sys
+import pathlib
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test
+
+async def printing():
+    for count in range(20):
+        print("Count: #" + str(count + 1))
+        await asyncio.sleep(0.1)
+
+async def hashing():
+    for count in range(10):
+        result = await source.password.from_plain_text(
+            "sample_password"
+        )
+
+        print("Hashed #" + str(count + 1) + " password")
+
+async def main():
+    await asyncio.gather(
+        hashing(),
+        printing()
+    )
+
+asyncio.run(main())

+ 39 - 0
server_tests/005-user.py

@@ -0,0 +1,39 @@
+import sys
+import pathlib
+import asyncio
+import tortoise
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test
+
+async def main():
+    modules = {
+        source.model.Meta.app: [ "server_source" ]
+    }
+
+    await tortoise.Tortoise.init(
+        db_url = "sqlite://:memory:", 
+        modules = modules
+    )
+    
+    await tortoise.Tortoise.generate_schemas()
+
+    first = await source.user_proxy.create("test", "password")
+    test(await first.compare_password("password"), True)
+
+    await first.result().save()
+
+    found = await source.user.filter(nick = "test").first()
+    found_proxy = source.user_proxy(found)
+    
+    test(found.nick, "test")
+    test(await found_proxy.compare_password("password"), True)
+
+    await tortoise.Tortoise.close_connections()
+
+asyncio.run(main())

+ 40 - 0
server_tests/006-user_manager.py

@@ -0,0 +1,40 @@
+import sys
+import pathlib
+
+test_file = pathlib.Path(__file__)
+project = test_file.parent.parent
+
+sys.path.append(str(project))
+
+import server_source as source
+from test import test
+
+import asyncio
+import tortoise
+
+async def main():
+    modules = {
+        source.model.Meta.app: [ "server_source" ]
+    }
+
+    await tortoise.Tortoise.init(
+        db_url = "sqlite://:memory:", 
+        modules = modules
+    )
+
+    await tortoise.Tortoise.generate_schemas()
+
+    created = await source.users_manager.register("test", "sample")
+    test(created.nick, "test")
+
+    by_apikey = await source.users_manager.get_by_apikey(created.apikey)
+    test(by_apikey.nick, "test")
+
+    loged = await source.users_manager.login("test", "sample")
+    test(loged.nick, "test")
+
+    not_loged = await source.users_manager.login("test", "not_working")
+    test(not_loged, None)
+    await tortoise.Tortoise.close_connections()
+
+asyncio.run(main())

+ 37 - 0
server_tests/test.py

@@ -0,0 +1,37 @@
+test_count = 0
+
+def test(result, expect) -> None:
+    global test_count
+    test_count += 1
+
+    if result == expect:
+        print("Test #" + str(test_count) + " SUCCESS.")
+        return
+
+    print("Test #" + str(test_count) + " FAIL.")
+    print("Expected: \"" + repr(expect) + "\".")
+    print("Result: \"" + repr(result) + "\".")
+    print()
+    exit(1)
+
+def test_error(error, function, *args, **kwargs) -> None:
+    global test_count
+    test_count += 1
+
+    try:
+        function(*args, **kwargs)
+        
+        print("Test #" + str(test_count) + " FAIL.")
+        print("Function not raise any exception.")
+        print()
+        exit(1)
+
+    except Exception as catched:
+        if type(catched) is not error:
+            print("Test #" + str(test_count) + " FAIL.")
+            print("Expected: \"" + repr(error) + "\".")
+            print("Catched: \"" + repr(catched) + "\".")
+            print()
+            exit(1)
+           
+        print("Test #" + str(test_count) + " SUCCESS.")

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels