Cixo Develop пре 5 месеци
родитељ
комит
857ea4006c

+ 3 - 0
application/theme/body.sass

@@ -1,3 +1,6 @@
 body    
     margin: 0px
     padding: 0px
+    color: $font
+    background-color: $background
+    transition: color 0.5s, background-color 0.5s

+ 10 - 2
application/theme/colors.sass

@@ -1,5 +1,13 @@
 $primary: #6BAB90
 $secondary: #364652
-$background: #FFFFFF
-$font: #000000
+$background: var(--background-color)
+$font: var(--font-color)
 $dark: #444444
+
+.white-theme
+    --font-color: #000000
+    --background-color: #FFFFFF
+
+.dark-theme
+    --font-color: #FFFFFF
+    --background-color: #222222

+ 62 - 0
application/theme/confirm_window.sass

@@ -0,0 +1,62 @@
+.confirm-window
+    position: fixed
+    top: 0px
+    left: 0px
+    width: 100%
+    height: 100%
+    display: flex
+    flex-direction: row
+    align-items: center
+    justify-content: center
+    background-color: rgba(0, 0, 0, 0.7)
+
+    .center
+        width: 300px
+        border-radius: 10px
+        border: 2px solid $primary
+        box-sizing: border-box
+        background-color: $secondary
+        background-color: $primary
+
+        .title h3
+            color: $secondary
+            text-align: center
+
+        .description 
+            background-color: $background
+            padding: 10px
+
+            p
+                text-align: center
+
+        .title, .description, .buttons
+            padding: 10px
+
+        .buttons
+            display: flex
+            flex-direction: row
+            align-items: center
+            justify-content: center
+            gap: 10px
+
+            .cancel, .confirm
+                padding: 10px
+                border: none
+                border-radius: 10px
+                transition: transform 0.5s
+
+            .cancel:hover
+                transform: rotate(30deg)
+
+            .confirm:hover
+                transform: scale(1.2)
+
+            .cancel
+                background-color: red
+                color: white
+            
+            .confirm
+                background-color: $secondary
+                color: $primary
+            
+

+ 2 - 0
application/theme/core.sass

@@ -4,3 +4,5 @@
 @import body
 @import login
 @import fullscreen
+@import scroll
+@import confirm_window

+ 4 - 0
application/theme/fonts.sass

@@ -5,6 +5,9 @@ body, input, button
     font-weight: normal
     font-optical-sizing: auto
 
+    @media only screen and (max-width: 230px)
+        word-break: break-all
+
 h1, h2, h3, h4, h5, h6
     font-family: "Cal Sans", sans-serif
     font-optical-sizing: auto
@@ -18,3 +21,4 @@ h1, h2, h3, h4, h5, h6
     font-weight: normal
     font-style: normal
     font-variation-settings: "wdth" 100
+

+ 114 - 7
application/theme/fullscreen.sass

@@ -5,22 +5,127 @@
     top: 0px
     left: 0px
     background-color: rgba(40, 40, 40, 0.7)
+    display: flex
+    flex-direction: row
+    align-items: center
+    justify-content: center
+
+    .center
+        box-sizing: border-box
+        width: 600px
+        max-width: 100%
+        background-color: $primary
+        color: $font
+        border: $primary 2px solid
+        border-radius: 10px
+        display: flex
+        flex-direction: column
+        align-items: stretch
+        justify-content: stretch
+
+        .result
+            background-color: $background
+
+        .content
+            box-sizing: border-box
+            width: 100%
+            background-color: $background
+            min-height: 100px
+            padding: 10px
+            display: flex
+            flex-direction: column
+            gap: 10px
+            align-items: center
+            justify-content: center
+       
+            .input-container
+                box-sizing: border-box
+                width: 100%
+                display: flex
+                flex-direction: row
+                flex-wrap: wrap
+                justify-content: space-between
+                align-items: center
+                gap: 10px
+
+                input
+                    padding: 5px
+                    border-radius: 5px
+                    color: $background
+                    background-color: $font
+                    border: $primary 2px solid
+                    transition: transform 0.5s
+                    max-width: 300px
+
+                    &:focus
+                        transform: scaleX(1.1)
+                        outline: 0 !important
+
+        .result
+            padding: 10px
+            min-height: 1.2em
 
+            .error, .success, .info
+                margin: 0px
+                text-align: center
+
+            .error
+                color: red
+
+            .success
+                color: lightgreen
+
+            .info
+                color: $font
+
+        .title h3
+            color: $background
+            text-align: center
+
+        .bottom
+            box-sizing: border-box
+            padding: 10px
+            display: flex
+            flex-direction: row
+            align-items: center
+            justify-content: center
+            gap: 10px
+
+            .close, .send
+                padding: 10px
+                border-radius: 10px
+                transition: transform 0.5s
+                border: none
+
+            .close
+                background-color: red
+                color: white
+    
+            .send
+                background-color: $background
+                color: $primary
+
+            .close:hover
+                transform: rotate(30deg)
+
+            .send:hover
+                transform: scale(1.2)
+
+.product-fullscreen-viewer
     .close
-        position: absolute
+        position: fixed
         top: 30px
         right: 30px
         padding: 10px
         border-radius: 10px
-        color: red
-        border: red 2px solid
-        background-color: rgba(0, 0, 0, 0)
+        border: none
+        background-color: red
+        color: white
         transition: transform 0.5s
 
         &:hover
-            transform: scale(1.2)
+            transform: rotate(30deg)
 
-.product-fullscreen-viewer
     .image
         width: 100%
         height: 100%
@@ -51,7 +156,6 @@
         left: 0px
         width: calc(100% - 20px)
         background-color: $font
-        color: $background
         display: flex
         flex-direction: column
         border-radius: 10px 10px 0px 0px
@@ -60,6 +164,9 @@
         justify-content: center
         vertical-align: center
 
+        p
+            color: $background
+
         .bottom-header, .description
             width: calc(100% - 40px)
             max-width: 500px

+ 5 - 1
application/theme/login.sass

@@ -4,11 +4,15 @@
     vertical-align: center
     justify-content: center
     align-items: center
+    flex-wrap: wrap
+    gap: 5px 0px
+
+    @media only screen and (max-width: 300px)
+        justify-content: center
 
     .login-info
         margin-right: 10px
         display: flex
-        vertical-align: center
         justify-content: center
         align-items: center
 

+ 52 - 12
application/theme/positions.sass

@@ -6,12 +6,18 @@
     display: flex
     flex-direction: row
     justify-content: space-between
-    flex-wrap: nowrap
+    flex-wrap: wrap
     align-content: center
     align-items: center
     background-color: $secondary
     color: $primary
 
+    @media only screen and (max-width: 800px) 
+        justify-content: space-around
+
+    @media only screen and (max-width: 600px) 
+        position: absolute
+
     input, button, select
         background-color: transparent
         color: $primary
@@ -21,6 +27,7 @@
         padding: 10px
         margin-left: 10px
         margin-right: 10px
+        box-sizing: border-box
 
     input:focus
         transform: scale(1.1)
@@ -37,14 +44,29 @@
         flex-direction: row
         align-items: stretch
         align-content: stretch
+        flex-wrap: wrap
+        justify-content: center
+        gap: 10px 0px
+
+        @media only screen and (max-width: 500px) 
+            input
+                width: calc(100% - 20px)
+
+            button, select
+                width: calc(50% - 20px)
+
+            button:hover
+                transform: rotate(5deg)
 
 .products   
     display: flex
     justify-content: space-evenly
-    align-items: center
-    align-content: center
+    align-items: stretch
     flex-wrap: wrap
     flex-direction: row
+    width: calc(100% - 40px)
+    margin: 20px
+    gap: 20px
 
     .top-bar-spacing
         width: 100%
@@ -53,8 +75,10 @@
         border: 2px solid $primary
         border-radius: 10px
         padding: 10px
-        margin: 10px
-        max-width: 500px
+        margin: 0px
+        max-width: 450px
+        width: 100%
+        box-sizing: border-box
 
         .header
             width: 100%
@@ -63,14 +87,24 @@
             align-items: center
             justify-content: space-between
 
+            @media only screen and (max-width: 450px) 
+                flex-direction: column
+                justify-content: center
+                margin-bottom: 10px 
+
+            .manage
+                display: flex
+                flex-direction: row
+                justify-content: center
+                flex-wrap: wrap
+                gap: 10px
+
             button
                 background-color: $primary
                 color: $secondary
                 border: none
                 padding: 10px
                 border-radius: 10px
-                margin-left: 5px
-                margin-right: 5px
                 transition: transform 0.5s
 
                 &:hover
@@ -108,14 +142,20 @@
             flex-direction: row
             flex-wrap: nowrap
             justify-content: space-between
+            gap: 10px
+
+            @media only screen and (max-width: 400px) 
+                flex-direction: column
 
     .title
-        width: calc(100% - 40px)
+        width: min(90%, 500px)
+        margin-left: calc(50% - min(90%, 500px) / 2)
+        margin-right: calc(50% - min(90%, 500px) / 2)
         text-align: center
-        margin: 20px
+        border: 2px solid $primary
+        padding: 10px
+        border-radius: 10px
+        box-sizing: border-box
 
         h1
-            border: 2px solid $primary
-            padding: 10px
-            border-radius: 10px
             display: inline

+ 23 - 0
application/theme/scroll.sass

@@ -0,0 +1,23 @@
+html
+    scroll-behavior: smooth
+
+.site-manage
+    position: fixed
+    bottom: 20px
+    left: 20px
+    display: flex 
+    flex-direction: column
+    justify-content: center
+    align-items: center
+    gap: 10px
+    
+    button
+        padding: 10px
+        border-radius: 10px
+        background-color: $secondary
+        color: $primary
+        border: $primary 2px solid
+        transition: transform 0.5s, opacity 0.5s
+
+        &:hover
+            transform: rotate(30deg)

+ 6 - 1
application/views/core.html

@@ -5,7 +5,7 @@
         <title>Reservationer</title>
         
         <meta charset="UTF-8">
-        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="viewport" content= "width=device-width, user-scalable=no, initial-scale=1.0">
         <meta name="description" content="Simple reservations app.">
 
         <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -43,5 +43,10 @@
                 </div>
             </div>
         </main>
+
+        <div class="site-manage">
+            <button class="scroll-up-button material-icons">arrow_upward</button>
+            <button class="reverse-colors material-icons">invert_colors</button>
+        </div>
     </body>
 </html>

+ 1 - 1
assets/__init__.py

@@ -52,4 +52,4 @@ from .app_resources import app_resources
 
 """ Image managment. """
 from .image import image
-from .directory_image import image
+from .directory_image import directory_image

+ 20 - 1
assets/app_config.py

@@ -1,12 +1,15 @@
 import pathlib
 
 from .config import config
+from .exception import config_exception
 
 class app_config(config):
     def __defaults() -> dict:
         return {
             "database_uri": "sqlite:///database.db",
-            "users_file": "users.json"
+            "users_file": "users.json",
+            "covers_dir": "covers/",
+            "thumbnails_dimension": "400"
         }
 
     def __init__(self):
@@ -20,7 +23,23 @@ class app_config(config):
     def users_file(self) -> str:
         return self._get("users_file")
 
+    @property
+    def covers_dir(self) -> str:
+        return self._get("covers_dir")
+
     @property
     def users_path(self) -> pathlib.Path:
         return pathlib.Path(self.users_file)
 
+    @property
+    def thumbnails_dimension(self) -> int:
+        try:
+            return int(self._get("thumbnails_dimension"))
+        
+        except:
+            raise config_exception("Thumbnails dimension must be integer.")
+
+    @property
+    def covers_path(self) -> pathlib.Path:
+        return pathlib.Path(self.covers_dir)
+

+ 16 - 1
assets/app_resources.py

@@ -7,6 +7,7 @@ from .users_loader import users_loader
 from .users_collection import users_collection
 from .product_app import product_app
 from .users_app import users_app
+from .directory_image import directory_image
 
 class app_resources:
     def __init__(self, config: object) -> None:
@@ -17,13 +18,27 @@ class app_resources:
 
         sqlmodel.SQLModel.metadata.create_all(self.database)
 
-        self.__product_app = product_app(self.database, self.users)
         self.__users_app = users_app(self.users)
+        
+        self.__directory_image = directory_image(
+            self.config.covers_path,
+            self.config.thumbnails_dimension
+        )
+        
+        self.__product_app = product_app(
+            self.database, 
+            self.users,
+            self.images
+        )
 
     @property
     def users_app(self) -> users_app:
         return self.__users_app
 
+    @property
+    def images(self) -> directory_image:
+        return self.__directory_image
+
     @property
     def product_app(self) -> product_app:
         return self.__product_app

+ 166 - 17
assets/directory_image.py

@@ -1,43 +1,192 @@
 import pathlib
 
 from .image import image
+from .product import product
 from .exception import directory_image_exception
 
 class directory_image:
+    """
+    This is image directory manager. It could store new images for product,
+    move it, when product name had been changed, and also remove images.
+    """
+
     def __init__(self, target: pathlib.Path, thumbnail: int = 400) -> None:
+        """ 
+        This initialize new manager.
+
+        Parameters;
+            target (pathlib.Path): Directory to store images in
+            thumbnail (int): Dimension of the thumbnails
+        """
+
         self.__target = target
         self.__thumbnail_size = thumbnail
 
         if not self.__target.is_dir():
-            content = "Directory for image hosting: \""
-            content = content + str(target) + "\"."
+            self.__target.mkdir()
 
-            raise directory_image_exception(content)
-    
     @property
     def thumbnail_size(self) -> int:
+        """ Diameter of the thumbnails. """
+
         return self.__thumbnail_size
 
+    def server_path() -> str:
+        """ This return where bind covers on server. """
+
+        return "/covers"
+
     @property
     def target(self) -> pathlib.Path:
+        """ Target directory of the manager. """
+
         return self.__target
 
-    def __name_full(self, image_id: int) -> str:
-        return "full_" + str(image_id) + "_.png"
+    def __name(self, target: product) -> str:
+        """ 
+        Base name of the image.
+        
+        Parameters: 
+            target (product): Target product to generate name of
+
+        Returns:
+            (str): Base part of the name
+        """
+
+        return target.name.encode("UTF-8").hex()
 
-    def __name_thumbnail(self, image_id: int) -> str:
-        return "thumbnail_" + str(image_id) + "_.webp"
+    def get_full_name(self, target: product) -> str:
+        """ 
+        Name of the full size image.
+        
+        Parameters: 
+            target (product): Target product to generate name of
 
-    def __full_path(self, image_id: int) -> pathlib.Path:
-        return self.target / pathlib.Path(self.__full_name(image_id))
+        Returns:
+            (str): Name of the full size file
+        """
 
-    def __thumbnail_path(self, image_id: int) -> pathlib.Path:
-        return self.target / pathlib.Path(self.__thumbnail_name(image_id))
+        return self.__name(target) + ".full.png"
 
-    def store(self, coded: str, image_id: int) -> None:
-        full = self.__full_path(image_id)
-        thumbnail = self.__full_path(image_id)
+    def get_thumbnail_name(self, target: product) -> str:
+        """ 
+        Name of the thumbnail.
         
-        image(coded, self.thumbnail_size) \
+        Parameters: 
+            target (product): Target product to generate name of
+
+        Returns:
+            (str): Name of the thumnail file
+        """
+
+        return self.__name(target) + ".thumbnail.webp"
+
+    def __full_path(self, target: product) -> pathlib.Path:
+        """
+        Path of the full size image.
+
+        Parameters:
+            target (product): Target product to generate path from
+
+        Returns:
+            (pathlib.Parh): Path of the image
+        """
+
+        return self.target / pathlib.Path(self.get_full_name(target))
+
+    def __thumbnail_path(self, target: product) -> pathlib.Path:
+        """
+        Path of the thumbnail image.
+
+        Parameters:
+            target (product): Target product to generate path from
+
+        Returns:
+            (pathlib.Parh): Path of the thumbnail
+        """
+
+        return self.target / pathlib.Path(self.get_thumbnail_name(target))
+
+    def save(self, content: image, target: product) -> None:
+        """
+        This save new image for new product.
+
+        Parameters:
+            coded (str): Base64 coded received image
+            target (product): Target product to save image for
+        """
+
+        full = self.__full_path(target)
+        thumbnail = self.__thumbnail_path(target)
+       
+        if full.is_file() or thumbnail.is_file(): 
+            content = "Images for product \"" + target.name + "\" "
+            content = content + "already exists."
+
+            raise directory_image_exception(content)
+
+        content \
         .save_full(full) \
-        .save_thumbnail(thumbnail)
+        .save_thumbnail(thumbnail, self.thumbnail_size)
+    
+    def drop(self, target: product) -> None:
+        """
+        This drop images for given product.
+
+        Parameters:
+            target (product): Target product to remove images for
+        """
+
+        full = self.__full_path(target)
+        thumbnail = self.__thumbnail_path(target)
+        
+        if not full.is_file() or not thumbnail.is_file():
+            content = "Can not remove images for \"" + target.name + "\" "
+            content = content + "it not exiets."
+
+            raise directory_image_exception(content)
+
+        full.unlink()
+        thumbnail.unlink()
+
+    def update(self, old: product, new: product) -> None:
+        """
+        This update names of the images for product. Name of the images is
+        connected with name of the product, and when name of the product 
+        had been changed, also images must be updated.
+
+        Parameters:
+            old (product): Product before update
+            new (product): Product after update
+        """
+
+        same_name = (
+            self.get_thumbnail_name(old) == self.get_thumbnail_name(new) and \
+            self.get_full_name(old) == self.get_full_name(new)
+        )
+
+        if same_name:
+            return
+
+        old_full = self.__full_path(old)
+        old_thumbnail = self.__thumbnail_path(old)
+
+        if not old_full.is_file() or not old_thumbnail.is_file():
+            content = "Can not move file for \"" + old.name + "\" "
+            content = content + "because not exists."
+
+            raise directory_image_exception(content)
+
+        new_full = self.__full_path(new)
+        new_thumbnail = self.__thumbnail_path(new)
+
+        if new_full.is_file() or new_thumbnail.is_file(): 
+            content = "Can not move, because images for \"" + new.name + "\" "
+            content = content + "already exists."
+
+            raise directory_image_exception(content)
+
+        old_full.rename(new_full)
+        old_thumbnail.rename(new_thumbnail)
+
+

+ 6 - 0
assets/exception.py

@@ -51,3 +51,9 @@ class incomplete_request_exception(Exception):
         content = content + "\"" + name + "\"."
 
         super().__init__(content)
+
+class reservation_exception(Exception):
+    pass
+
+class reservation_loader_exception(Exception):
+    pass

+ 52 - 9
assets/image.py

@@ -7,56 +7,99 @@ import pathlib
 from .exception import image_exception
 
 class image:
+    """ 
+    This class is responsible for image storing. It get image coded as base64
+    validate it, and when all went well, it could be saved and also thumbnail 
+    could be generated.
+    """
+
     def __init__(
         self, 
         coded: str, 
-        thumbnail: int = 400,
         max_size: int = 2 * 1024 * 1024 * 16
     ) -> None:
+        """
+        This initialize new image saver.
+
+        Parameters:
+            coded (str): Image as base64 content
+            thumbnail (int): Diameters of image thumbnail (default = 400)
+            max_size (int): Max size of image file (default = 16Mb)
+        """
+
         if len(coded) > max_size:
             raise image_exception()
 
-        self.__thumbnail = thumbnail
         self.__content = base64.standard_b64decode(coded)
 
         if not self.valid:
             raise image_exception()
 
-    @property
-    def thumbnail(self) -> tuple:
-        return (self.__thumbnail, self.__thumbnail)
-
     @property
     def content(self) -> bytes:
+        """ Image as bytes. """
+
         return self.__content
 
     @property
     def __file(self) -> io.BytesIO:
+        """  Image bytes as IO file like buffer. """
+
         return io.BytesIO(self.content)
 
     @property
     def __image(self) -> PIL.Image:
+        """ New PIL Image object from received bytes. """
+
         try:
             return PIL.Image.open(self.__file)
         
         except:
             raise image_exception()
         
-    def save_thumbnail(self, location: pathlib.Path) -> object:
+    def save_thumbnail(
+        self, 
+        location: pathlib.Path,
+        diameter: int = 400
+    ) -> object:
+        """
+        This save generated thumbnail in given location. Format of the 
+        thumbnail is defined by location extension.
+        
+        Parameters:
+            location (pathlib.Path): Place to store thumbnail
+
+        Returns:
+            (image): Self to chain dot calls
+        """
+
         with self.__image as image:
-            image.thumbnail(self.thumbnail)
+            image.thumbnail((diameter, diameter))
             image.save(location)
         
         return self
 
     def save_full(self, location: pathlib.Path) -> object:
+        """
+        This save received image in given location. Format of the image is 
+        based on the location extension.
+
+        Parameters:
+            location (pathlib.Path): Place to store image
+
+        Returns:
+            (image): Self to chain dot calls
+        """
+
         with self.__image as image:
             image.save(location)
 
         return self
 
     @property
-    def valid(self) -> bool:    
+    def valid(self) -> bool:   
+        """ True when image is valid, false when not. """
+
         try:
             with self.__image as image:
                 image.verify()

+ 45 - 17
assets/product.py

@@ -12,11 +12,18 @@ class product(sqlmodel.SQLModel, table = True):
     shared.
     """
 
+    """ Name of the table to use in foreign keys. """
+    __tablename__: str = "product"
+
     """ ID of the product in the database. """
     id: int | None = sqlmodel.Field(default = None, primary_key = True)
 
     """ Name of the product, or title of the book. """
-    name: str | None = sqlmodel.Field(default = None, index = True)
+    name: str | None = sqlmodel.Field(
+        default = None, 
+        index = True, 
+        unique = True
+    )
 
     """ Description of the product, provided by creator. """
     description: str | None = sqlmodel.Field(default = None)
@@ -24,9 +31,6 @@ class product(sqlmodel.SQLModel, table = True):
     """ Author of the product. """
     author: str | None = sqlmodel.Field(default = None)
 
-    """ Link to image of the product. """
-    image: str | None = sqlmodel.Field(default = None)
-
     """ Count of instances this products in stock. """
     stock_count: int = sqlmodel.Field(default = 0)
 
@@ -37,6 +41,43 @@ class product(sqlmodel.SQLModel, table = True):
         unique = True
     )
 
+    def save_copy(self) -> object:
+        """
+        This return clone of the product, which is not connect with 
+        database.
+
+        Returns:
+            (product): Clone of the product, which is not database connected
+        """
+
+        result = product()
+        result.name = self.name
+        result.description = self.description
+        result.author = self.author
+        result.stock_count = self.stock_count
+        result.barcode = self.barcode
+
+        return result
+
+    def restore_copy(self, target: object) -> object:
+        """
+        This restore content of the product, from the save copy.
+
+        Parameters:
+            target (product): Target product to restore
+
+        Returns:
+            (product): Return product itself
+        """
+
+        self.name = target.name
+        self.description = target.description
+        self.author = target.author
+        self.stock_count = target.stock_count
+        self.barcode = target.barcode
+
+        return self
+
     @property
     def in_database(self) -> bool:
         """ This return True when product exists in database. """
@@ -66,7 +107,6 @@ class product(sqlmodel.SQLModel, table = True):
         content = content + "Description: " + str(self.description) + "\n"
         content = content + "Author: " + str(self.author) + "\n"
         content = content + "Barcode (EAN): " + str(self.barcode) + "\n"
-        content = content + "Image (link): " + str(self.image) + "\n"
         content = content + "In stock: " + str(self.stock_count) + "\n"
 
         return content
@@ -201,18 +241,6 @@ class product_factory:
 
         self.__target.author = target
 
-    @property
-    def image(self) -> str | None:
-        """ This return image link of the product. """
-
-        return self.__target.image
-
-    @image.setter
-    def image(self, target: str | None) -> None:
-        """ This set image link of the product. """
-
-        self.__target.image = target
-
     @property
     def stock_count(self) -> int:
         """ This return how much product is in stock. """

+ 81 - 11
assets/product_app.py

@@ -12,15 +12,21 @@ from .exception import bad_request_exception
 from .exception import not_found_exception
 from .exception import access_denied_exception
 from .users_collection import users_collection
+from .image import image
+from .directory_image import directory_image
 
 class product_app(app_route_database):
     def __init__(
         self, 
         connection: sqlalchemy.engine.base.Engine,
-        users: users_collection
+        users: users_collection,
+        images: directory_image
+
     ) -> None:
         super().__init__(connection)
         self.__users_collection = users
+        self.__response = product_response(images)
+        self.__images_manager = images
 
     def all(self) -> dict:
         try:
@@ -78,16 +84,37 @@ class product_app(app_route_database):
         except Exception as error:
             return self._fail(str(error))
 
+    def __image(self, send: dict) -> image | None:
+        if not "image" in send:
+            return None
+
+        return image(send["image"])
+
     def create(self, send: dict) -> dict:
         try:
             if not self.__logged_in(send):
                 raise access_denied_exception()
+                
+            target = product_builder().modify(send).result
+            image = self.__image(send)
+
+            if image is None:
+                raise bad_request_exception("not contain image")
 
             with self.__products_database as loader:
-                target = product_builder().modify(send).result
                 result = loader.store(target)
-
-                return self.__modify(result, "Can nod create product.")
+               
+                if not result:
+                    return self.__modify(result, "Can not create product.")
+               
+                try:
+                    self.__images_manager.save(image, target)
+                except Exception as error:
+                    loader.drop(target)
+                    raise error
+
+                return self._success()
+                
 
         except Exception as error:
             return self._fail(str(error))
@@ -140,11 +167,49 @@ class product_app(app_route_database):
 
             with self.__products_database as loader:
                 target = self.__select_by_sended(send, loader)
+                
+                old = target.save_copy()
                 updated = product_builder(target).modify(send).result
-                result = loader.store(updated)
+                
+                if not loader.store(updated):
+                    return self._fail("Can not update product.")
+                
+                try:
+                    self.__images_manager.update(old, updated)
+                except Exception as error:
+                    updated.restore_copy(old)
+                    
+                    if not loader.store(updated):
+                        error = "Image caould be moved. Can not restore "
+                        error = error + "previous product. Fatal error."
+
+                        return self._fail(error)
+
+                    raise error
+                
+                return self._success()
 
-                return self.__modify(result, "Can not update product.")
+        except Exception as error:
+            return self._fail(str(error))
 
+    def update_image(self, send: dict) -> dict:
+        try:
+            if not self.__logged_in(send):
+                raise access_denied_exception()
+
+            image = self.__image(send)
+
+            if image is None:
+                raise bad_request_exception("Not found image in request.")
+            
+            with self.__products_database as loader:
+                target = self.__select_by_sended(send, loader)
+                
+            self.__images_manager.drop(target)
+            self.__images_manager.save(image, target)
+
+            return self._success()
+    
         except Exception as error:
             return self._fail(str(error))
 
@@ -155,10 +220,15 @@ class product_app(app_route_database):
 
             with self.__products_database as loader:
                 target = self.__select_by_sended(send, loader)
-                result = loader.drop(target)
+                copy = target.save_copy()
                 
-                return self.__modify(result, "Can not delete product.")
-        
+                if not loader.drop(target):
+                    return self._fail("Can not delete product.")
+                
+            self.__images_manager.drop(copy)
+
+            return self._success()
+
         except Exception as error:
             return self._fail(str(error))
 
@@ -175,10 +245,10 @@ class product_app(app_route_database):
         if target is None:
             return self._fail("Can not found product in database.")
 
-        return self._success(product = product_response(target))
+        return self._success(product = self.__response.single(target))
 
     def __collection(self, target: typing.Iterable[product]) -> dict:
-        return self._success(collection = product_response(target))
+        return self._success(collection = self.__response.collection(target))
 
     @property
     def __users(self) -> users_collection:

+ 0 - 3
assets/product_builder.py

@@ -20,9 +20,6 @@ class product_builder:
         if "author" in target:
             factory.author = target["author"]
 
-        if "image" in target:
-            factory.image = target["image"]
-
         if "stock_count" in target:
             factory.stock_count = int(target["stock_count"])
 

+ 15 - 17
assets/product_response.py

@@ -1,43 +1,41 @@
 import typing
 import collections.abc
 
+from .directory_image import directory_image
 from .product import product
 from .exception import not_ready_exception
 
 class product_response:
-    def __new__(
-        cls, 
-        target: product | typing.Iterable[product]
-    ) -> list | dict:
-        if isinstance(target, product):
-            return cls.single(target)
+    def __init__(self, images: directory_image) -> None:
+        self.__images = images
 
-        if isinstance(target, collections.abc.Iterable):
-            return cls.collection(target)
+    @property
+    def images(self) -> directory_image:    
+        return self.__images
 
-        raise TypeError("Bad type for product response generator.")
-
-
-    def single(target: product) -> dict:
+    def single(self, target: product) -> dict:
         if not target.ready:
             raise not_ready_exception(target)
 
+        covers = directory_image.server_path() + "/"
+
         return {
             "name": target.name,
             "description": target.description,
             "author": target.author,
-            "image": target.image,
+            "image": covers + self.images.get_full_name(target),
+            "thumbnail": covers + self.images.get_thumbnail_name(target),
             "stock_count": target.stock_count,
             "barcode": target.barcode
         }
 
-    def collection(targets: typing.Iterable[product]) -> list:
-        result = list()
+    def collection(self, targets: typing.Iterable[product]) -> list:
+        result = list() 
 
         for count in targets:
             if not isinstance(count, product):
-                raise TypeError("Products iterable must contain products.")
+                raise TypeError("Product list must contain only products.")
 
-            result.append(product_response.single(count))
+            result.append(self.single(count))
 
         return result

+ 41 - 3
assets/requests.py

@@ -28,12 +28,11 @@ class user_get_request(pydantic.BaseModel):
         }
     }
 
-class product_request(pydantic.BaseModel):
+class product_update_request(pydantic.BaseModel):
     apikey: str
     name: str
     description: str
     author: str
-    image: str
     stock_count: int
     barcode: str
     
@@ -45,7 +44,6 @@ class product_request(pydantic.BaseModel):
                     "name": "Product Name",
                     "description": "Product description.",
                     "author": "Product author.",
-                    "image": "https://api.com/image.png",
                     "stocik_count": 10,
                     "barcode": "509282819938"
                 }
@@ -53,6 +51,46 @@ class product_request(pydantic.BaseModel):
         }
     }
 
+class product_create_request(pydantic.BaseModel):
+    apikey: str
+    name: str
+    description: str
+    author: str
+    image: str
+    stock_count: str
+    barcode: str
+
+    model_config = {
+        "json_schema_extra": {
+            "examples": [
+                {
+                    "apikey": "af...69",
+                    "name": "Product Name",
+                    "description": "Product description.",
+                    "author": "Product author.",
+                    "image": "ddshfgiuhiugde... base64 encoded image",
+                    "stocik_count": 10,
+                    "barcode": "509282819938"
+                }
+            ]
+        }
+    }
+
+class product_update_image_request(pydantic.BaseModel):
+    apikey: str
+    image: str
+
+    model_config = {
+        "json_schema_extra": {
+            "examples": [
+                {
+                    "apikey": "af...69",
+                    "image": "lfjskhgshgkfj base64 encoded image"
+                }
+            ]
+        }
+    }
+
 class apikey_request(pydantic.BaseModel):
     apikey: str
     

+ 99 - 0
assets/reservation.py

@@ -0,0 +1,99 @@
+import sqlmodel
+
+from .product import product
+from .exception import reservation_exception
+from .validator import phone_number_validator
+from .validator import email_validator
+
+class reservation(sqlmodel.SQLModel, table = True):
+    __tablename__: str = "reservation"
+
+    id: int | None = sqlmodel.Field(default = None, primary_key = True)
+    
+    email: str | None = sqlmodel.Field(
+        default = None,
+        index = True,
+        unique = False
+    )
+
+    phone_number: str | None = sqlmodel.Field(
+        default = None,
+        index = True,
+        unique = False
+    )
+
+    target_id: int | None = sqlmodel.Field(
+        default = None, 
+        foreign_key = "product.id",
+        unique = False
+    ) 
+
+    target: product | None = sqlmodel.Relationshio(
+        back_populates = "product"
+    )
+
+    @property 
+    def in_database(self) -> bool:
+        return self.id is not None
+
+    @property
+    def ready(self) -> bool:
+        if self.target is None:
+            return False
+
+        if self.email is None and self.phone_number is None:
+            return False
+        
+        return True
+
+    def __str__(self) -> str:
+        content = "Reservation "
+
+        if self.in_database:
+            content = content + "#" + str(self.id)
+        
+        content = content + "\n"
+        
+        if self.email is not None:
+            content = content + "E-mail: \"" + self.email + "\"\n"
+        
+        if self.phone_number is not None:
+            content = content + "Phone: \"" + self.phone_number + "\"\n"
+        
+        if self.target is None:
+            return content + "Target: not set"
+
+        return content + "Target: #" + str(self.target) + "\n"
+    
+class reservation_factory:
+    def __init__(self, target: reservation | None = None) -> None:
+        self.__target = target
+
+        if self.__target is None:
+            self.__target = reservation()
+
+    def target(self, item: product) -> object:
+        if item.in_database:
+            raise reservation_exception("Target item not in database.")
+
+        self.__target.target = item.id
+        return self
+
+    def phone_number(self, number: str) -> object:
+        if not phone_number_validator(number).result:
+            raise reservation_exception("Phone number is not valid.")
+
+        self.__target.phone_number = number
+        return self
+
+    def email(self, email: str) -> object:
+        if not email_validator(email).result:
+            raise reservation_exception("Email is not valid.")
+
+        self.__target.email = email
+        return self
+    
+    def result() -> reservation:
+        return self.__target
+
+        

+ 54 - 0
assets/reservation_loader.py

@@ -0,0 +1,54 @@
+import sqlmodel
+import sqlmodel.sql._expression_select_cls
+
+from .product import product
+from .reservation import reservation
+from .reservation import reservation_factory
+from .reservations_collection import reservations_collection
+
+class reservation_loader(sqlmodel.Session): 
+    def get_by_email(self, target: str) -> reservations_collection:
+        pass
+
+    def get_by_phone_number(self, target: str) -> reservations_collection:
+        pass
+
+    def get_by_target(self, target: product) -> reservations_collection:
+        pass
+
+    def store(self, target: reservation) -> bool:
+        if not target.ready:
+            raise RuntimeError("Reservation is not setup.")
+
+        if target.in_database:
+            error = "Target reservation is already in database. Reservation "
+            error = error + "object is not editable."
+
+            raise reservation_loader_exception(error)
+
+        try:
+            self.add(target)
+            self.commit()
+            self.refresh(target)
+
+            return True
+        
+        except:
+            return False
+
+    def drop(self, target: reservation) -> bool:
+        if not target.in_database:
+            raise reservation_loader_exception("Reservation does not exists.")
+
+        try:
+            self.delete(target)
+            self.commit()
+
+            return True
+
+        except:
+            return False
+
+    @property
+    def __select(self) -> sqlmodel.sql._expression_select_cls.SelectOfScalar:
+        return sqlmodel.select(reservation)

+ 36 - 0
assets/reservations_collection.py

@@ -0,0 +1,36 @@
+from .product import product
+from .reservation import reservation
+
+class reservations_collection:
+    def __init__(self) -> None:
+        self.__content = list()
+
+    def append(self, target: reservation) -> object:
+        self.__content.append(target)
+        return self
+
+    def _filter(self, action: function) -> object:
+        new = list()
+
+        for item in self.__content:
+            if action(item):
+                new.append(item)
+
+        self.__content = new
+        return self
+
+    def by_target(self, target: product) -> object:
+        if not target.in_database:
+            raise reservation_exception("Target product must be in database.")
+
+        return self._filter(lambda item: item.target_id == target.id)
+
+    def by_email(self, target: str) -> object:
+        return self._filter(lambda item: item.email == target)
+
+    def by_phone_number(self, target: str) -> object:
+        return self._filter(lambda item: item.phone_number == target)
+    
+    @property
+    def result(self) -> list:
+        return self.__content

+ 35 - 5
assets/server.py

@@ -42,15 +42,25 @@ class server(fastapi.FastAPI):
                     status_code = 200
                 )
     
-        directory = fastapi.staticfiles.StaticFiles(
+        app_directory = fastapi.staticfiles.StaticFiles(
             directory = pathlib.Path("static/")
         )
 
+        covers_directory = fastapi.staticfiles.StaticFiles(
+            directory = self.resources.config.covers_path 
+        )
+
         self.mount(
             "/app", 
-            directory, 
+            app_directory, 
             name = "app_frontend"
         )
+
+        self.mount(
+            directory_image.server_path(),
+            covers_directory,
+            name = "covers"
+        )
     
     def __route_product(self) -> None:
         @self.get("/products")
@@ -84,7 +94,7 @@ class server(fastapi.FastAPI):
         @self.post("/product/update/barcode/{barcode}")
         async def product_barcode_update(
             barcode: str, 
-            product: product_request
+            product: product_update_request
         ) -> dict:
             body = product.dict()
             body["target_barcode"] = barcode
@@ -94,13 +104,33 @@ class server(fastapi.FastAPI):
         @self.post("/product/update/name/{name}")
         async def product_name_update(
             name: str, 
-            product: product_request
+            product: product_update_request
         ) -> dict:
             body = product.dict()
             body["target_name"] = name
 
             return self.product_app.update(body)
 
+        @self.post("/product/update/image/barcode/{barcode}")
+        async def product_image_barcode_update(
+            barcode: str,
+            product_image: product_update_image_request
+        ) -> dict:
+            body = product_image.dict()
+            body["target_barcode"] = barcode
+
+            return self.product_app.update_image(body)
+    
+        @self.post("/product/update/image/name/{name}")
+        async def product_image_barcode_update(
+            name: str,
+            product_image: product_update_image_request
+        ) -> dict:
+            body = product_image.dict()
+            body["target_name"] = name
+
+            return self.product_app.update_image(body)
+
         @self.delete("/product/barcode/{barcode}")
         async def product_barcode_delete(
             barcode: str,
@@ -122,7 +152,7 @@ class server(fastapi.FastAPI):
             return self.product_app.delete(body)
 
         @self.post("/product/create")
-        async def product_create(product: product_request) -> dict:
+        async def product_create(product: product_create_request) -> dict:
             return self.product_app.create(product.dict())
 
     def __route_users(self) -> None:    

+ 81 - 0
assets/validator.py

@@ -214,3 +214,84 @@ class author_validator(validator):
         # Must has max 40 characters
         if len(self.target) > 40:
             return self._failed()
+
+class phone_number_validator(validator):
+    """
+    This is phone number validator. Phone number must be in format
+    like +CC XXXXXXXXX. This is standard of E.123 and E.164.
+    """
+
+    def _check_all(self) -> None:
+        
+        # Separate country code and number
+        splited = self.target.split(" ")
+
+        if len(splited) != 2:
+            return self._failed()
+
+        # Get it
+        country_code = splited[0]
+        number = splited[1]
+        
+        # Must start with "+"
+        if country_code[0] != "+":
+            return self._failed()
+
+        country_code = country_code[1:]
+        
+        # Country code must be numeric
+        if not country_code.isnumeric():
+            return self._failed()
+        
+        # Number of course must be numeric
+        if not number.isnumeric():
+            return self._failed()
+
+        # Country code length must be <1;3>
+        if len(country_code) == 0 or len(country_code) > 3:
+            return self._failed()
+
+        # All number must have less than 16
+        if len(country_code) + len(number) > 15:
+            return self._failed()
+
+        # And number mast has minimum 7 length
+        if len(number) < 7:
+            return self._failed()
+
+class email_validator(validator):
+    """
+    This is email validator. This validate email address in the standard
+    format.
+    """
+
+    def _check_all(self) -> None:
+        
+        # Split name and domain
+        splited = self.target.split("@")
+        
+        if len(splited) != 2:
+            return self._failed()
+
+        # Gat it
+        name = splited[0]
+        domain = splited[1]
+        
+        # Name must be in <1;128>
+        if len(name) < 1 or len(name) > 128:
+            return self._failed()
+
+        # Domain can not has ".."
+        if domain.find("..") != -1:
+            return self._failed()
+
+        splited = domain.split(".")
+        
+        # Must has more or equal 2 parts in domain
+        if len(splited) < 2:
+            return self._failed()
+
+        # All parts must be in <1;128>
+        for part in splited:
+            if len(part) < 1 or len(part) > 128:
+                return self._failed()


+ 1 - 0
maketool

@@ -0,0 +1 @@
+Subproject commit f90cef46cf5b2587acc1cba4e0a432cda8b4e34f

Разлика између датотеке није приказан због своје велике величине
+ 957 - 275
static/bundle/app.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/bundle/theme.css


+ 6 - 1
static/core.html

@@ -5,7 +5,7 @@
         <title>Reservationer</title>
         
         <meta charset="UTF-8">
-        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="viewport" content= "width=device-width, user-scalable=no, initial-scale=1.0">
         <meta name="description" content="Simple reservations app.">
 
         <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -43,5 +43,10 @@
                 </div>
             </div>
         </main>
+
+        <div class="site-manage">
+            <button class="scroll-up-button material-icons">arrow_upward</button>
+            <button class="reverse-colors material-icons">invert_colors</button>
+        </div>
     </body>
 </html>

+ 46 - 0
tests/008-directory_image.py

@@ -0,0 +1,46 @@
+import pathlib
+
+current = pathlib.Path(__file__).parent
+root = current.parent
+
+import sys
+sys.path.append(str(root))
+
+import assets
+
+import base64
+
+test = pathlib.Path("./test")
+test.mkdir()
+
+sample_product = assets.product()
+sample_product.name = "sample"
+
+print("Create sample directory.")
+sample = assets.directory_image(test)
+
+print("Opening sample file.")
+with open("sample.png", "rb") as sample_file:
+    sample_image_content = base64.b64encode(sample_file.read())
+    sample_image = assets.image(sample_image_content)
+
+print()
+
+print("Save it.")
+sample.save(sample_image, sample_product)
+print()
+
+input("Prese enter after validate:")
+print("Move files...")
+
+sample_product_sec = assets.product()
+sample_product_sec.name = "sample 2"
+sample.update(sample_product, sample_product_sec)
+print()
+
+input("Prese enter after validate:")
+print("Remove files...")
+
+sample.drop(sample_product_sec)
+
+test.rmdir()

Неке датотеке нису приказане због велике количине промена