Browse Source

deleted Database class. Reworked all view, so that they don't need a database instance

Dimitri Korsch 3 years ago
parent
commit
b5d784be18
68 changed files with 1159 additions and 1134 deletions
  1. 3 14
      app.py
  2. 1 0
      migrations/README
  3. 50 0
      migrations/alembic.ini
  4. 91 0
      migrations/env.py
  5. 24 0
      migrations/script.py.mako
  6. 125 0
      migrations/versions/b03df3e31b8d_.py
  7. 8 2
      pycs/__init__.py
  8. 5 4
      pycs/database/Collection.py
  9. 0 201
      pycs/database/Database.py
  10. 9 0
      pycs/database/File.py
  11. 18 4
      pycs/database/Label.py
  12. 2 2
      pycs/database/LabelProvider.py
  13. 6 4
      pycs/database/Model.py
  14. 21 15
      pycs/database/Project.py
  15. 6 2
      pycs/database/base.py
  16. 0 49
      pycs/database/discovery/LabelProviderDiscovery.py
  17. 0 32
      pycs/database/discovery/ModelDiscovery.py
  18. 0 0
      pycs/database/discovery/__init__.py
  19. 6 3
      pycs/database/util/JSONEncoder.py
  20. 1 1
      pycs/database/util/__init__.py
  21. 115 97
      pycs/frontend/WebServer.py
  22. 2 6
      pycs/frontend/endpoints/ListLabelProviders.py
  23. 2 6
      pycs/frontend/endpoints/ListModels.py
  24. 2 6
      pycs/frontend/endpoints/ListProjects.py
  25. 6 4
      pycs/frontend/endpoints/additional/FolderInformation.py
  26. 6 18
      pycs/frontend/endpoints/data/GetFile.py
  27. 4 9
      pycs/frontend/endpoints/data/GetPreviousAndNextFile.py
  28. 3 8
      pycs/frontend/endpoints/data/GetResizedFile.py
  29. 13 20
      pycs/frontend/endpoints/data/RemoveFile.py
  30. 24 20
      pycs/frontend/endpoints/data/UploadFile.py
  31. 7 5
      pycs/frontend/endpoints/jobs/RemoveJob.py
  32. 18 15
      pycs/frontend/endpoints/labels/CreateLabel.py
  33. 14 16
      pycs/frontend/endpoints/labels/EditLabelName.py
  34. 14 16
      pycs/frontend/endpoints/labels/EditLabelParent.py
  35. 5 13
      pycs/frontend/endpoints/labels/ListLabelTree.py
  36. 5 13
      pycs/frontend/endpoints/labels/ListLabels.py
  37. 23 22
      pycs/frontend/endpoints/labels/RemoveLabel.py
  38. 11 14
      pycs/frontend/endpoints/pipelines/FitModel.py
  39. 9 13
      pycs/frontend/endpoints/pipelines/PredictFile.py
  40. 66 63
      pycs/frontend/endpoints/pipelines/PredictModel.py
  41. 73 64
      pycs/frontend/endpoints/projects/CreateProject.py
  42. 16 17
      pycs/frontend/endpoints/projects/EditProjectDescription.py
  43. 17 18
      pycs/frontend/endpoints/projects/EditProjectName.py
  44. 37 34
      pycs/frontend/endpoints/projects/ExecuteExternalStorage.py
  45. 20 24
      pycs/frontend/endpoints/projects/ExecuteLabelProvider.py
  46. 5 13
      pycs/frontend/endpoints/projects/GetProjectModel.py
  47. 0 40
      pycs/frontend/endpoints/projects/ListCollections.py
  48. 24 0
      pycs/frontend/endpoints/projects/ListProjectCollections.py
  49. 14 15
      pycs/frontend/endpoints/projects/ListProjectFiles.py
  50. 16 28
      pycs/frontend/endpoints/projects/RemoveProject.py
  51. 6 11
      pycs/frontend/endpoints/results/ConfirmResult.py
  52. 43 33
      pycs/frontend/endpoints/results/CreateResult.py
  53. 14 16
      pycs/frontend/endpoints/results/EditResultData.py
  54. 15 17
      pycs/frontend/endpoints/results/EditResultLabel.py
  55. 5 9
      pycs/frontend/endpoints/results/GetProjectResults.py
  56. 3 11
      pycs/frontend/endpoints/results/GetResults.py
  57. 10 13
      pycs/frontend/endpoints/results/RemoveResult.py
  58. 14 16
      pycs/frontend/endpoints/results/ResetResults.py
  59. 60 0
      pycs/frontend/endpoints/results/ResultAsCrop.py
  60. 14 6
      pycs/frontend/util/JSONEncoder.py
  61. 5 8
      pycs/interfaces/MediaFile.py
  62. 3 3
      pycs/interfaces/MediaFileList.py
  63. 1 1
      pycs/interfaces/MediaLabel.py
  64. 9 12
      pycs/interfaces/MediaStorage.py
  65. 2 2
      pycs/jobs/Job.py
  66. 1 1
      pycs/jobs/JobRunner.py
  67. 2 0
      settings.json
  68. 5 5
      test/test_database.py

+ 3 - 14
app.py

@@ -1,20 +1,9 @@
 #!/usr/bin/env python
 
-import os
-import json
-
+from pycs import app
+from pycs import settings
 from pycs.frontend.WebServer import WebServer
 
-print('- Loading settings')
-with open('settings.json') as file:
-    settings = json.load(file)
-
-# create projects folder
-if not os.path.exists('projects/'):
-    os.mkdir('projects/')
-
-# start web server
-server = WebServer(settings)
-
 if __name__ == '__main__':
+    server = WebServer(app, settings)
     server.run()

+ 1 - 0
migrations/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 50 - 0
migrations/alembic.ini

@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = INFO
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 91 - 0
migrations/env.py

@@ -0,0 +1,91 @@
+from __future__ import with_statement
+
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option(
+    'sqlalchemy.url',
+    str(current_app.extensions['migrate'].db.get_engine().url).replace(
+        '%', '%%'))
+target_metadata = current_app.extensions['migrate'].db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url, target_metadata=target_metadata, literal_binds=True
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    connectable = current_app.extensions['migrate'].db.get_engine()
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            process_revision_directives=process_revision_directives,
+            **current_app.extensions['migrate'].configure_args
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
migrations/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}

+ 125 - 0
migrations/versions/b03df3e31b8d_.py

@@ -0,0 +1,125 @@
+"""empty message
+
+Revision ID: b03df3e31b8d
+Revises: 
+Create Date: 2021-08-11 12:46:17.757283
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'b03df3e31b8d'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('label_provider',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(), nullable=False),
+    sa.Column('description', sa.String(), nullable=True),
+    sa.Column('root_folder', sa.String(), nullable=False),
+    sa.Column('configuration_file', sa.String(), nullable=False),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('root_folder', 'configuration_file')
+    )
+    op.create_table('model',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(), nullable=False),
+    sa.Column('description', sa.String(), nullable=True),
+    sa.Column('root_folder', sa.String(), nullable=False),
+    sa.Column('supports_encoded', sa.String(), nullable=False),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('root_folder')
+    )
+    op.create_table('project',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(), nullable=False),
+    sa.Column('description', sa.String(), nullable=True),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('model_id', sa.Integer(), nullable=True),
+    sa.Column('label_provider_id', sa.Integer(), nullable=True),
+    sa.Column('root_folder', sa.String(), nullable=False),
+    sa.Column('external_data', sa.Boolean(), nullable=False),
+    sa.Column('data_folder', sa.String(), nullable=False),
+    sa.ForeignKeyConstraint(['label_provider_id'], ['label_provider.id'], ondelete='SET NULL'),
+    sa.ForeignKeyConstraint(['model_id'], ['model.id'], ondelete='SET NULL'),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('root_folder')
+    )
+    op.create_index(op.f('ix_project_created'), 'project', ['created'], unique=False)
+    op.create_table('collection',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(), nullable=False),
+    sa.Column('project_id', sa.Integer(), nullable=False),
+    sa.Column('reference', sa.String(), nullable=False),
+    sa.Column('description', sa.String(), nullable=True),
+    sa.Column('position', sa.Integer(), nullable=False),
+    sa.Column('autoselect', sa.Boolean(), nullable=False),
+    sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('project_id', 'reference')
+    )
+    op.create_table('label',
+    sa.Column('name', sa.String(), nullable=False),
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('project_id', sa.Integer(), nullable=False),
+    sa.Column('parent_id', sa.Integer(), nullable=True),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('reference', sa.String(), nullable=True),
+    sa.ForeignKeyConstraint(['parent_id'], ['label.id'], ondelete='SET NULL'),
+    sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('project_id', 'reference')
+    )
+    op.create_index(op.f('ix_label_created'), 'label', ['created'], unique=False)
+    op.create_table('file',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(), nullable=False),
+    sa.Column('uuid', sa.String(), nullable=False),
+    sa.Column('extension', sa.String(), nullable=False),
+    sa.Column('type', sa.String(), nullable=False),
+    sa.Column('size', sa.String(), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('path', sa.String(), nullable=False),
+    sa.Column('frames', sa.Integer(), nullable=True),
+    sa.Column('fps', sa.Float(), nullable=True),
+    sa.Column('project_id', sa.Integer(), nullable=False),
+    sa.Column('collection_id', sa.Integer(), nullable=True),
+    sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='SET NULL'),
+    sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('project_id', 'path')
+    )
+    op.create_index(op.f('ix_file_created'), 'file', ['created'], unique=False)
+    op.create_table('result',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('file_id', sa.Integer(), nullable=False),
+    sa.Column('origin', sa.String(), nullable=False),
+    sa.Column('type', sa.String(), nullable=False),
+    sa.Column('label_id', sa.Integer(), nullable=True),
+    sa.Column('data_encoded', sa.String(), nullable=True),
+    sa.ForeignKeyConstraint(['file_id'], ['file.id'], ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['label_id'], ['label.id'], ondelete='SET NULL'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('result')
+    op.drop_index(op.f('ix_file_created'), table_name='file')
+    op.drop_table('file')
+    op.drop_index(op.f('ix_label_created'), table_name='label')
+    op.drop_table('label')
+    op.drop_table('collection')
+    op.drop_index(op.f('ix_project_created'), table_name='project')
+    op.drop_table('project')
+    op.drop_table('model')
+    op.drop_table('label_provider')
+    # ### end Alembic commands ###

+ 8 - 2
pycs/__init__.py

@@ -1,6 +1,8 @@
 import json
+import os
 
 from pathlib import Path
+from munch import munchify
 
 from flask import Flask
 from flask_migrate import Migrate
@@ -10,11 +12,15 @@ from sqlalchemy.engine import Engine
 
 print('- Loading settings')
 with open('settings.json') as file:
-    settings = json.load(file)
+    settings = munchify(json.load(file))
 
 
+# create projects folder
+if not os.path.exists(settings.projects_folder):
+    os.mkdir(settings.projects_folder)
+
 app = Flask(__name__)
-app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{Path.cwd() / settings['database']}"
+app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{Path.cwd() / settings.database}"
 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
 
 @event.listens_for(Engine, "connect")

+ 5 - 4
pycs/database/Collection.py

@@ -1,5 +1,7 @@
+from __future__ import annotations
+
 from contextlib import closing
-from typing import Iterator
+from typing import List
 
 from pycs import db
 from pycs.database.base import NamedBaseModel
@@ -41,8 +43,6 @@ class Collection(NamedBaseModel):
         "autoselect",
     )
 
-    def count_files(self) -> int:
-        return self.files.count()
 
     def get_files(self, offset: int = 0, limit: int = -1):
         """
@@ -55,6 +55,7 @@ class Collection(NamedBaseModel):
         from pycs.database.File import File
         return self.files.order_by(File.id).offset(offset).limit(limit)
 
+
     @staticmethod
     def update_autoselect(collections: List[Collection]) -> List[Collection]:
         """ disable autoselect if there are no elements in the collection """
@@ -68,7 +69,7 @@ class Collection(NamedBaseModel):
             if found:
                 collection.autoselect = False
 
-            elif collection.count_files() == 0:
+            elif collection.files.count() == 0:
                 collection.autoselect = False
                 found = True
 

+ 0 - 201
pycs/database/Database.py

@@ -1,201 +0,0 @@
-import sqlite3
-from contextlib import closing
-from time import time
-from typing import Optional, Iterator
-
-from pycs import db
-from pycs.database.Collection import Collection
-from pycs.database.File import File
-from pycs.database.LabelProvider import LabelProvider
-from pycs.database.Model import Model
-from pycs.database.Project import Project
-from pycs.database.Result import Result
-from pycs.database.discovery.LabelProviderDiscovery import discover as discover_label_providers
-from pycs.database.discovery.ModelDiscovery import discover as discover_models
-
-
-class Database:
-    """
-    opens an sqlite database and allows to access several objects
-    """
-
-    def __init__(self, discovery: bool = True):
-        """
-        wrapper for some DB-related runctions. TODO: remove it!
-
-        """
-
-        if discovery:
-            discover_models()
-            discover_label_providers()
-
-    def close(self):
-        """
-        close database file
-        """
-        return
-
-    def copy(self):
-        """
-        Create a copy of this database object. This can be used to access the database
-        from another thread. Table initialization and model and label provider discovery is
-        disabled to speedup this function.
-
-        :return: Database
-        """
-        return self
-
-    def commit(self):
-        """
-        commit changes
-        """
-        db.session.commit()
-
-    def get_object_by_id(self, table_name: str, identifier: int, cls):
-        """
-        create an object from cls and a row fetched from table_name and
-        identified by the identifier
-
-        :param table_name: table name
-        :param identifier: unique identifier
-        :param cls: class that is used to create the object
-        :return: object of type cls
-        """
-        with closing(self.con.cursor()) as cursor:
-            cursor.execute(f'SELECT * FROM {table_name} WHERE id = ?', [identifier])
-            row = cursor.fetchone()
-
-            if row is not None:
-                return cls(self, row)
-
-            return None
-
-    def get_objects(self, table_name: str, cls):
-        """
-        get a list of all available objects in the table
-
-        :param table_name: table name
-        :param cls: class that is used to create the objects
-        :return: list of object of type cls
-        """
-
-        with closing(self.con.cursor()) as cursor:
-            cursor.execute(f'SELECT * FROM {table_name}')
-            for row in cursor:
-                yield cls(self, row)
-
-
-    def models(self) -> Iterator[Model]:
-        """
-        get a list of all available models
-
-        :return: iterator of models
-        """
-        return self.get_objects("models", Model)
-
-    def model(self, identifier: int) -> Optional[Model]:
-        """
-        get a model using its unique identifier
-
-        :param identifier: unique identifier
-        :return: model
-        """
-        return self.get_object_by_id("models", identifier, Model)
-
-    def label_providers(self) -> Iterator[LabelProvider]:
-        """
-        get a list of all available label providers
-
-        :return: iterator over label providers
-        """
-        return self.get_objects("label_providers", LabelProvider)
-
-    def label_provider(self, identifier: int) -> Optional[LabelProvider]:
-        """
-        get a label provider using its unique identifier
-
-        :param identifier: unique identifier
-        :return: label provider
-        """
-        return self.get_object_by_id("label_providers", identifier, LabelProvider)
-
-    def projects(self) -> Iterator[Project]:
-        """
-        get a list of all available projects
-
-        :return: iterator over projects
-        """
-        return self.get_objects("projects", Project)
-
-    def project(self, identifier: int) -> Optional[Project]:
-        """
-        get a project using its unique identifier
-
-        :param identifier: unique identifier
-        :return: project
-        """
-        return self.get_object_by_id("projects", identifier, Project)
-
-    def create_project(self,
-                       name: str,
-                       description: str,
-                       model: Model,
-                       label_provider: Optional[LabelProvider],
-                       root_folder: str,
-                       external_data: bool,
-                       data_folder: str):
-        """
-        insert a project into the database
-
-        :param name: project name
-        :param description: project description
-        :param model: used model
-        :param label_provider: used label provider (optional)
-        :param root_folder: path to project folder
-        :param external_data: whether an external data directory is used
-        :param data_folder: path to data folder
-        :return: created project
-        """
-        # prepare some values
-        created = int(time())
-        label_provider_id = label_provider.identifier if label_provider is not None else None
-
-        # insert statement
-        with closing(self.con.cursor()) as cursor:
-            cursor.execute('''
-                INSERT INTO projects (
-                    name, description, created, model, label_provider, root_folder, external_data, 
-                    data_folder
-                )
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-            ''', (name, description, created, model.identifier, label_provider_id, root_folder,
-                  external_data, data_folder))
-
-            return self.project(cursor.lastrowid)
-
-    def collection(self, identifier: int) -> Optional[Collection]:
-        """
-        get a collection using its unique identifier
-
-        :param identifier: unique identifier
-        :return: collection
-        """
-        return self.get_object_by_id("collections", identifier, Collection)
-
-    def file(self, identifier) -> Optional[File]:
-        """
-        get a file using its unique identifier
-
-        :param identifier: unique identifier
-        :return: file
-        """
-        return self.get_object_by_id("files", identifier, File)
-
-    def result(self, identifier) -> Optional[Result]:
-        """
-        get a result using its unique identifier
-
-        :param identifier: unique identifier
-        :return: result
-        """
-        return self.get_object_by_id("results", identifier, Result)

+ 9 - 0
pycs/database/File.py

@@ -1,6 +1,8 @@
 from __future__ import annotations
 
+import os
 import typing as T
+import warnings
 
 from datetime import datetime
 from pathlib import Path
@@ -80,6 +82,13 @@ class File(NamedBaseModel):
 
         return str(Path.cwd() / path)
 
+    def delete(self, commit: bool = True):
+        super().delete(commit=commit)
+        # remove file from folder
+        os.remove(self.path)
+        # TODO: remove temp files
+        warnings.warn("Temporary files may still exist!")
+
     @commit_on_return
     def set_collection(self, collection_id: T.Optional[int]):
         """

+ 18 - 4
pycs/database/Label.py

@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from datetime import datetime
 
 from pycs import db
@@ -63,14 +65,26 @@ class Label(NamedBaseModel):
     )
 
     @commit_on_return
-    def set_parent(self, parent_id: int) -> None:
+    def set_parent(self, parent: T.Optional[T.Union[int, str, Label]] = None) -> None:
+
         """
         set this labels parent
 
-        :param parent_id: parent's id
+        :param parent: parent label. Can be a reference, an id or a Label instance
         :return:
         """
-        if not compare_children(self, parent_id):
-            raise ValueError('Cyclic relationship detected!')
+        parent_id = None
+        if parent is not None:
+            if isinstance(parent, Label):
+                parent_id = parent.id
+
+            elif isinstance(parent, str):
+                parent_id = Label.query.filter(Label.reference == parent).one().id
+
+            elif isinstance(parent, int):
+                parent_id = parent
+
+            if not compare_children(self, parent_id):
+                raise ValueError('Cyclic relationship detected!')
 
         self.parent_id = parent_id

+ 2 - 2
pycs/database/LabelProvider.py

@@ -14,7 +14,7 @@ class LabelProvider(NamedBaseModel):
     """
 
     description = db.Column(db.String)
-    root_folder = db.Column(db.String, nullable=False, unique=True)
+    root_folder = db.Column(db.String, nullable=False)
     configuration_file = db.Column(db.String, nullable=False)
 
     # relationships to other models
@@ -48,7 +48,7 @@ class LabelProvider(NamedBaseModel):
             # returns None if not present
             provider.description = config.get('description')
 
-            db.session.flush()
+            provider.flush()
         db.session.commit()
 
     @property

+ 6 - 4
pycs/database/Model.py

@@ -40,13 +40,15 @@ class Model(NamedBaseModel):
             description = config.get('description', None)
             supports = config['supports']
 
-            model, _ = cls.get_or_create(root_folder=str(folder))
+            model, is_new = cls.get_or_create(root_folder=str(folder))
+
+            assert model is not None
 
             model.name = name
             model.description = description
             model.supports = supports
 
-            db.session.flush()
+            model.flush()
         db.session.commit()
 
     @property
@@ -65,9 +67,9 @@ class Model(NamedBaseModel):
             raise ValueError(f"Not supported type: {type(value)}")
 
     @commit_on_return
-    def copy_to(self, new_name: str, new_root_folder: str):
+    def copy_to(self, name: str, root_folder: str):
 
-        model, is_new = Model.get_or_create(root_folder=new_root_folder)
+        model, is_new = Model.get_or_create(root_folder=root_folder)
 
         model.name = name
         model.description = self.description

+ 21 - 15
pycs/database/Project.py

@@ -1,4 +1,5 @@
 import os
+import shutil
 import typing as T
 import warnings
 
@@ -63,6 +64,20 @@ class Project(NamedBaseModel):
         "data_folder",
     )
 
+    @commit_on_return
+    def delete(self) -> T.Tuple[dict, dict]:
+        dump = super().delete(commit=False)
+
+        model_dump = {}
+        if self.model_id is not None:
+            model_dump = self.model.delete(commit=False)
+
+        # remove from file system
+        shutil.rmtree(self.root_folder)
+
+        return dump, model_dump
+
+
 
     def label(self, identifier: int) -> T.Optional[Label]:
         """
@@ -122,14 +137,14 @@ class Project(NamedBaseModel):
                             JOIN tree ON labels.parent = tree.id
                     )
                 SELECT * FROM tree
-            ''', [self.identifier])
+            ''', [self.id])
 
             result = []
             lookup = {}
 
             for row in cursor.fetchall():
                 label = TreeNodeLabel(self.database, row)
-                lookup[label.identifier] = label
+                lookup[label.id] = label
 
                 if label.parent_id is None:
                     result.append(label)
@@ -162,7 +177,7 @@ class Project(NamedBaseModel):
     @commit_on_return
     def create_label(self, name: str,
                      reference: str = None,
-                     parent_id: int = None,
+                     parent: T.Optional[T.Union[int, str, Label]] = None,
                      hierarchy_level: str = None) -> T.Tuple[T.Optional[Label], bool]:
         """
         create a label for this project. If there is already a label with the same reference
@@ -170,7 +185,7 @@ class Project(NamedBaseModel):
 
         :param name: label name
         :param reference: label reference
-        :param parent_id: parent's identifier
+        :param parent: parent label. Either a reference string, a Label id or a Label instance
         :param hierarchy_level: hierarchy level name
         :return: created or edited label, insert
         """
@@ -183,7 +198,7 @@ class Project(NamedBaseModel):
             is_new = True
 
         label.set_name(name, commit=False)
-        label.set_parent(parent_id, commit=False)
+        label.set_parent(parent, commit=False)
         label.hierarchy_level = hierarchy_level
 
         return label, is_new
@@ -258,15 +273,6 @@ class Project(NamedBaseModel):
         return file, is_new
 
 
-    def count_files(self) -> int:
-        """
-        count files associated with this project
-
-        :return: count
-        """
-        return self.files.count()
-
-
     def get_files(self, offset: int = 0, limit: int = -1) -> T.List[File]:
         """
         get an iterator of files associated with this project
@@ -275,7 +281,7 @@ class Project(NamedBaseModel):
         :param limit: file limit
         :return: iterator of files
         """
-        return self.files.order_by(File.id).offset(offset).limit(limit).all()
+        return self.files.order_by(File.id).offset(offset).limit(limit)
 
 
     def _files_without_results(self):

+ 6 - 2
pycs/database/base.py

@@ -30,7 +30,9 @@ class BaseModel(db.Model, SerializerMixin):
 
 
     def serialize(self) -> dict:
-        return self.to_dict()
+        res = self.to_dict()
+        res["identifier"] = self.id
+        return res
 
 
     @commit_on_return
@@ -56,7 +58,9 @@ class BaseModel(db.Model, SerializerMixin):
         db.session.add(obj)
 
         if commit:
-            self.commit()
+            obj.commit()
+
+        return obj
 
     @classmethod
     def get_or_create(cls, **kwargs) -> T.Tuple[BaseModel, bool]:

+ 0 - 49
pycs/database/discovery/LabelProviderDiscovery.py

@@ -1,49 +0,0 @@
-import re
-from contextlib import closing
-from glob import glob
-from json import load
-from os import path, listdir
-
-
-def __find_files():
-    # list folders in labels/
-    for folder in glob('labels/*'):
-        # list files
-        for filename in listdir(folder):
-            file_path = path.join(folder, filename)
-
-            # filter configuration files
-            if not path.isfile(file_path):
-                continue
-
-            if not re.match(r'^configuration(\d+)?\.json$', filename):
-                continue
-
-            # yield element
-            yield folder, filename, file_path
-
-
-def discover(database):
-    """
-    find label providers in the corresponding folder and add them to the database
-
-    :param database:
-    :return:
-    """
-    with closing(database.cursor()) as cursor:
-        for folder, configuration_file, configuration_path in __find_files():
-            # load configuration file
-            with open(configuration_path, 'r') as file:
-                label = load(file)
-
-            # extract data
-            name = label['name']
-            description = label['description'] if 'description' in label else None
-
-            # save to database
-            cursor.execute('''
-                INSERT INTO label_providers (name, description, root_folder, configuration_file)
-                VALUES (?, ?, ?, ?)
-                ON CONFLICT (root_folder, configuration_file)
-                DO UPDATE SET name = ?, description = ?
-            ''', (name, description, folder, configuration_file, name, description))

+ 0 - 32
pycs/database/discovery/ModelDiscovery.py

@@ -1,32 +0,0 @@
-from contextlib import closing
-from glob import glob
-from json import load, dumps
-from os import path
-
-
-def discover(database):
-    """
-    find models in the corresponding folder and add them to the database
-
-    :param database:
-    :return:
-    """
-    with closing(database.cursor()) as cursor:
-        # list folders in models/
-        for folder in glob('models/*'):
-            # load distribution.json
-            with open(path.join(folder, 'configuration.json'), 'r') as file:
-                model = load(file)
-
-            # extract data
-            name = model['name']
-            description = model['description'] if 'description' in model else None
-            supports = dumps(model['supports'])
-
-            # save to database
-            cursor.execute('''
-                INSERT INTO models (name, description, root_folder, supports)
-                VALUES (?, ?, ?, ?)
-                ON CONFLICT (root_folder)
-                DO UPDATE SET name = ?, description = ?, supports = ?
-            ''', (name, description, folder, supports, name, description, supports))

+ 0 - 0
pycs/database/discovery/__init__.py


+ 6 - 3
pycs/database/util/JSONEncoder.py

@@ -2,6 +2,7 @@ from typing import Any
 
 from flask.json import JSONEncoder as Base
 
+from pycs.database.base import BaseModel
 
 class JSONEncoder(Base):
     """
@@ -9,6 +10,8 @@ class JSONEncoder(Base):
     """
 
     def default(self, o: Any) -> Any:
-        copy = o.__dict__.copy()
-        del copy['database']
-        return copy
+        if isinstance(o, BaseModel):
+            return o.serialize()
+
+        else:
+            return o.__dict__.copy()

+ 1 - 1
pycs/database/util/__init__.py

@@ -4,7 +4,7 @@ from pycs import db
 
 def commit_on_return(method):
 
-	@warps(method)
+	@wraps(method)
 	def inner(self, *args, commit: bool = True, **kwargs):
 
 		res = method(self, *args, **kwargs)

+ 115 - 97
pycs/frontend/WebServer.py

@@ -11,7 +11,8 @@ from flask import Flask
 from flask import send_from_directory
 
 from pycs import app
-from pycs.database.Database import Database
+from pycs.database.Model import Model
+from pycs.database.LabelProvider import LabelProvider
 from pycs.frontend.endpoints.ListJobs import ListJobs
 from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
 from pycs.frontend.endpoints.ListModels import ListModels
@@ -39,8 +40,8 @@ from pycs.frontend.endpoints.projects.EditProjectName import EditProjectName
 from pycs.frontend.endpoints.projects.ExecuteExternalStorage import ExecuteExternalStorage
 from pycs.frontend.endpoints.projects.ExecuteLabelProvider import ExecuteLabelProvider
 from pycs.frontend.endpoints.projects.GetProjectModel import GetProjectModel
-from pycs.frontend.endpoints.projects.ListCollections import ListCollections
-from pycs.frontend.endpoints.projects.ListFiles import ListFiles
+from pycs.frontend.endpoints.projects.ListProjectCollections import ListProjectCollections
+from pycs.frontend.endpoints.projects.ListProjectFiles import ListProjectFiles
 from pycs.frontend.endpoints.projects.RemoveProject import RemoveProject
 from pycs.frontend.endpoints.results.ConfirmResult import ConfirmResult
 from pycs.frontend.endpoints.results.CreateResult import CreateResult
@@ -64,7 +65,6 @@ class WebServer:
     # pylint: disable=line-too-long
     # pylint: disable=too-many-statements
     def __init__(self, app, settings: dict):
-        self.database = Database()
 
         PRODUCTION = os.path.exists('webui/index.html')
 
@@ -104,19 +104,25 @@ class WebServer:
 
             # create service objects
             self.__sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
-            self.__app = socketio.WSGIApp(self.__sio, app)
+            self.app = app
+            self.wsgi_app = socketio.WSGIApp(self.__sio, app)
 
             # set access control header to allow requests from Vue.js development server
-            @app.after_request
+            @self.app.after_request
             def after_request(response):
                 # pylint: disable=unused-variable
                 response.headers['Access-Control-Allow-Origin'] = '*'
                 return response
 
         # set json encoder so database objects are serialized correctly
-        app.json_encoder = JSONEncoder
+        self.app.json_encoder = JSONEncoder
+
+        self.host = settings.host
+        self.port = settings.port
 
         # create notification manager
+        jobs = JobRunner()
+        pipelines = PipelineCache(jobs)
         notifications = NotificationManager(self.__sio)
 
         jobs.on_create(notifications.create_job)
@@ -127,187 +133,199 @@ class WebServer:
 
         self.define_routes(jobs, notifications, pipelines)
 
+        Model.discover("models/")
+        LabelProvider.discover("labels/")
+
 
     def define_routes(self, jobs, notifications, pipelines):
 
         # additional
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/folder',
             view_func=FolderInformation.as_view('folder_information')
         )
 
         # jobs
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/jobs',
             view_func=ListJobs.as_view('list_jobs', jobs)
         )
-        app.add_url_rule(
-            '/jobs/<identifier>/remove',
+        self.app.add_url_rule(
+            '/jobs/<int:job_id>/remove',
             view_func=RemoveJob.as_view('remove_job', jobs)
         )
 
         # models
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/models',
-            view_func=ListModels.as_view('list_models', self.database)
+            view_func=ListModels.as_view('list_models')
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/model',
-            view_func=GetProjectModel.as_view('get_project_model', self.database)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/model',
+            view_func=GetProjectModel.as_view('get_project_model')
         )
 
         # labels
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/label_providers',
-            view_func=ListLabelProviders.as_view('label_providers', self.database)
+            view_func=ListLabelProviders.as_view('label_providers')
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/labels',
-            view_func=ListLabels.as_view('list_labels', self.database)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/labels',
+            view_func=ListLabels.as_view('list_labels')
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/labels/tree',
-            view_func=ListLabelTree.as_view('list_label_tree', self.database)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/labels/tree',
+            view_func=ListLabelTree.as_view('list_label_tree')
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/labels',
-            view_func=CreateLabel.as_view('create_label', self.database, notifications)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/labels',
+            view_func=CreateLabel.as_view('create_label', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/labels/<int:label_id>/remove',
-            view_func=RemoveLabel.as_view('remove_label', self.database, notifications)
+            view_func=RemoveLabel.as_view('remove_label', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/labels/<int:label_id>/name',
-            view_func=EditLabelName.as_view('edit_label_name', self.database, notifications)
+            view_func=EditLabelName.as_view('edit_label_name', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/labels/<int:label_id>/parent',
-            view_func=EditLabelParent.as_view('edit_label_parent', self.database, notifications)
+            view_func=EditLabelParent.as_view('edit_label_parent', notifications)
         )
 
         # collections
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/collections',
-            view_func=ListCollections.as_view('list_collections', self.database)
+            view_func=ListProjectCollections.as_view('list_collections')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/data/<int:collection_id>/<int:start>/<int:length>',
-            view_func=ListFiles.as_view('list_collection_files', self.database)
+            view_func=ListProjectFiles.as_view('list_collection_files')
         )
 
         # data
-        app.add_url_rule(
-            '/projects/<int:identifier>/data',
-            view_func=UploadFile.as_view('upload_file', self.database, notifications)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/data',
+            view_func=UploadFile.as_view('upload_file', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/data/<int:start>/<int:length>',
-            view_func=ListFiles.as_view('list_files', self.database)
+            view_func=ListProjectFiles.as_view('list_files')
         )
-        app.add_url_rule(
-            '/data/<int:identifier>/remove',
-            view_func=RemoveFile.as_view('remove_file', self.database, notifications)
+        self.app.add_url_rule(
+            '/data/<int:file_id>/remove',
+            view_func=RemoveFile.as_view('remove_file', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>',
-            view_func=GetFile.as_view('get_file', self.database)
+            view_func=GetFile.as_view('get_file')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/<resolution>',
-            view_func=GetResizedFile.as_view('get_resized_file', self.database)
+            view_func=GetResizedFile.as_view('get_resized_file')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/<resolution>/<crop_box>',
             view_func=GetCroppedFile.as_view('crop_result', database)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/previous_next',
-            view_func=GetPreviousAndNextFile.as_view('get_previous_and_next_file', self.database)
+            view_func=GetPreviousAndNextFile.as_view('get_previous_and_next_file')
         )
 
         # results
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/results',
-            view_func=GetProjectResults.as_view('get_project_results', self.database)
+            view_func=GetProjectResults.as_view('get_project_results')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/results',
-            view_func=GetResults.as_view('get_results', self.database)
+            view_func=GetResults.as_view('get_results')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/results',
-            view_func=CreateResult.as_view('create_result', self.database, notifications)
+            view_func=CreateResult.as_view('create_result', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/reset',
-            view_func=ResetResults.as_view('reset_results', self.database, notifications)
+            view_func=ResetResults.as_view('reset_results', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/results/<int:result_id>/remove',
-            view_func=RemoveResult.as_view('remove_result', self.database, notifications)
+            view_func=RemoveResult.as_view('remove_result', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/results/<int:result_id>/confirm',
-            view_func=ConfirmResult.as_view('confirm_result', self.database, notifications)
+            view_func=ConfirmResult.as_view('confirm_result', notifications)
+        )
+        self.app.add_url_rule(
+            '/results/<int:result_id>/crop',
+            view_func=ResultAsCrop.as_view('crop_result')
+        )
+        self.app.add_url_rule(
+            '/results/<int:result_id>/crop/<int:max_width>',
+            view_func=ResultAsCrop.as_view('crop_result_resized_by_width')
+        )
+        self.app.add_url_rule(
+            '/results/<int:result_id>/crop/<int:max_width>x<int:max_height>',
+            view_func=ResultAsCrop.as_view('crop_result_resized')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/results/<int:result_id>/label',
-            view_func=EditResultLabel.as_view('edit_result_label', self.database, notifications)
+            view_func=EditResultLabel.as_view('edit_result_label', notifications)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/results/<int:result_id>/data',
-            view_func=EditResultData.as_view('edit_result_data', self.database, notifications)
+            view_func=EditResultData.as_view('edit_result_data', notifications)
         )
 
         # projects
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects',
-            view_func=ListProjects.as_view('list_projects', self.database)
+            view_func=ListProjects.as_view('list_projects')
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects',
-            view_func=CreateProject.as_view('create_project', self.database, notifications, jobs)
+            view_func=CreateProject.as_view('create_project', notifications, jobs)
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/label_provider',
-            view_func=ExecuteLabelProvider.as_view('execute_label_provider', self.database,
-                                                   notifications, jobs)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/label_provider',
+            view_func=ExecuteLabelProvider.as_view('execute_label_provider', notifications, jobs)
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/external_storage',
-            view_func=ExecuteExternalStorage.as_view('execute_external_storage', self.database,
-                                                     notifications, jobs)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/external_storage',
+            view_func=ExecuteExternalStorage.as_view('execute_external_storage', notifications, jobs)
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/remove',
-            view_func=RemoveProject.as_view('remove_project', self.database, notifications)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/remove',
+            view_func=RemoveProject.as_view('remove_project', notifications)
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/name',
-            view_func=EditProjectName.as_view('edit_project_name', self.database, notifications)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/name',
+            view_func=EditProjectName.as_view('edit_project_name', notifications)
         )
-        app.add_url_rule(
-            '/projects/<int:identifier>/description',
-            view_func=EditProjectDescription.as_view('edit_project_description', self.database,
-                                                     notifications)
+        self.app.add_url_rule(
+            '/projects/<int:project_id>/description',
+            view_func=EditProjectDescription.as_view('edit_project_description', notifications)
         )
 
         # pipelines
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/pipelines/fit',
-            view_func=FitModel.as_view('fit_model', self.database, jobs, pipelines)
+            view_func=FitModel.as_view('fit_model', jobs, pipelines)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/projects/<int:project_id>/pipelines/predict',
-            view_func=PredictModel.as_view('predict_model', self.database, notifications, jobs,
+            view_func=PredictModel.as_view('predict_model', notifications, jobs,
                                            pipelines)
         )
-        app.add_url_rule(
+        self.app.add_url_rule(
             '/data/<int:file_id>/predict',
-            view_func=PredictFile.as_view('predict_file', self.database, notifications, jobs, pipelines)
+            view_func=PredictFile.as_view('predict_file', notifications, jobs, pipelines)
         )
 
     def run(self):
         # finally start web server
-        eventlet.wsgi.server(eventlet.listen((self.host, self.port)), app)
+        eventlet.wsgi.server(eventlet.listen((self.host, self.port)), self.wsgi_app)

+ 2 - 6
pycs/frontend/endpoints/ListLabelProviders.py

@@ -1,7 +1,7 @@
 from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.LabelProvider import LabelProvider
 
 
 class ListLabelProviders(View):
@@ -11,10 +11,6 @@ class ListLabelProviders(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self):
-        label_providers = list(self.db.label_providers())
-        return jsonify(label_providers)
+        return jsonify(LabelProvider.query.all())

+ 2 - 6
pycs/frontend/endpoints/ListModels.py

@@ -1,7 +1,7 @@
 from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Model import Model
 
 
 class ListModels(View):
@@ -11,10 +11,6 @@ class ListModels(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self):
-        models = list(self.db.models())
-        return jsonify(models)
+        return jsonify(Model.query.all())

+ 2 - 6
pycs/frontend/endpoints/ListProjects.py

@@ -1,7 +1,7 @@
 from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 
 
 class ListProjects(View):
@@ -11,10 +11,6 @@ class ListProjects(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self):
-        projects = list(self.db.projects())
-        return jsonify(projects)
+        return jsonify(Project.query.all())

+ 6 - 4
pycs/frontend/endpoints/additional/FolderInformation.py

@@ -1,6 +1,8 @@
-from os import path, listdir
+import os
 
-from flask import request, abort, jsonify
+from flask import abort
+from flask import jsonify
+from flask import request
 from flask.views import View
 
 
@@ -21,12 +23,12 @@ class FolderInformation(View):
 
         # check if directory exists
         result = {
-            'exists': path.exists(folder)
+            'exists': os.path.exists(folder)
         }
 
         # count files
         if result['exists']:
-            result['count'] = len(listdir(folder))
+            result['count'] = len(os.listdir(folder))
 
         # send result
         return jsonify(result)

+ 6 - 18
pycs/frontend/endpoints/data/GetFile.py

@@ -1,9 +1,9 @@
-from os import path, getcwd
+import os
 
-from flask import abort, send_from_directory
+from flask import send_from_directory
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.File import File
 
 
 class GetFile(View):
@@ -13,23 +13,11 @@ class GetFile(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
-
     def dispatch_request(self, file_id: int):
-        # get file from database
-        file = self.db.file(file_id)
-
-        if file is None:
-            return abort(404)
+        file = File.get_or_404(file_id)
 
-        # get absolute path
-        if path.isabs(file.path):
-            abs_file_path = file.path
-        else:
-            abs_file_path = path.join(getcwd(), file.path)
+        abs_file_path = file.absolute_path
 
         # return data
-        file_directory, file_name = path.split(abs_file_path)
+        file_directory, file_name = os.path.split(abs_file_path)
         return send_from_directory(file_directory, file_name)

+ 4 - 9
pycs/frontend/endpoints/data/GetPreviousAndNextFile.py

@@ -1,7 +1,8 @@
-from flask import abort, jsonify
+from flask import abort
+from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.File import File
 
 
 class GetPreviousAndNextFile(View):
@@ -11,16 +12,10 @@ class GetPreviousAndNextFile(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self, file_id: int):
         # get file from database
-        file = self.db.file(file_id)
-
-        if file is None:
-            return abort(404)
+        file = File.get_or_404(file_id)
 
         # get previous and next
         result = {

+ 3 - 8
pycs/frontend/endpoints/data/GetResizedFile.py

@@ -6,7 +6,7 @@ from flask import abort
 from flask import send_from_directory
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.File import File
 from pycs.util.FileOperations import resize_file
 
 
@@ -17,17 +17,12 @@ class GetResizedFile(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self, file_id: int, resolution: str):
         # get file from database
-        file = self.db.file(file_id)
-        if file is None:
-            return abort(404)
+        file = File.get_or_404(file_id)
 
-        project = file.project()
+        project = file.project
 
         if not os.path.exists(file.absolute_path):
             abort(404, "File not found!")

+ 13 - 20
pycs/frontend/endpoints/data/RemoveFile.py

@@ -3,7 +3,8 @@ from os import remove
 from flask import make_response, request, abort
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
+from pycs.database.File import File
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -14,38 +15,30 @@ class RemoveFile(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, file_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'remove' not in data or data['remove'] is not True:
-            return abort(400)
+        if not data.get('remove', False):
+            abort(400, "remove flag is missing")
 
         # start transaction
-        with self.db:
+        with db.session.begin_nested():
             # find file
-            file = self.db.file(identifier)
-            if file is None:
-                return abort(400)
+            file = File.get_or_404(file_id)
 
             # check if project uses an external data directory
-            project = file.project()
-            if project.external_data:
-                return abort(400)
+            if file.project.external_data:
+                abort(400,
+                    "Cannot remove file, project is setup with external data!")
 
             # remove file from database
-            file.remove()
-
-            # remove file from folder
-            remove(file.path)
-
-            # TODO remove temp files
+            file_dump = file.delete()
 
         # send notification
-        self.nm.remove_file(file)
+        self.nm.remove_file(file_dump)
         return make_response()

+ 24 - 20
pycs/frontend/endpoints/data/UploadFile.py

@@ -1,12 +1,12 @@
-from os import path
-from uuid import uuid1
+import os
+import uuid
 
 from eventlet import tpool
 from flask import make_response, request, abort
 from flask.views import View
 from werkzeug import formparser
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.util.FileOperations import file_info
 
@@ -18,23 +18,19 @@ class UploadFile(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
         self.data_folder = None
-        self.file_id = None
+        self.file_uuid = None
         self.file_name = None
         self.file_extension = None
         self.file_size = None
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id: int):
         # find project
-        project = self.db.project(identifier)
-
-        if project is None:
-            return abort(404, "Project not found")
+        project = Project.get_or_404(project_id)
 
         # abort if external storage is used
         if project.external_data:
@@ -42,11 +38,12 @@ class UploadFile(View):
 
         # get upload path and id
         self.data_folder = project.data_folder
-        self.file_id = str(uuid1())
+        self.file_uuid = str(uuid.uuid1())
 
         # parse upload data
         _, _, files = tpool.execute(formparser.parse_form_data,
-                                    request.environ, stream_factory=self.custom_stream_factory)
+                                    request.environ,
+                                    stream_factory=self.custom_stream_factory)
 
         # abort if there is no file entry in uploaded data
         if 'file' not in files.keys():
@@ -55,14 +52,21 @@ class UploadFile(View):
         # detect file type
         try:
             ftype, frames, fps = tpool.execute(file_info,
-                                               self.data_folder, self.file_id, self.file_extension)
+                                               self.data_folder,
+                                               self.file_uuid,
+                                               self.file_extension)
         except ValueError as exception:
             return abort(400, str(exception))
 
-        # add to project files
-        with self.db:
-            file, _ = project.add_file(self.file_id, ftype, self.file_name, self.file_extension,
-                                       self.file_size, self.file_id, frames, fps)
+        file, _ = project.add_file(
+            uuid=self.file_uuid,
+            file_type=ftype,
+            name=self.file_name,
+            extension=self.file_extension,
+            size=self.file_size,
+            filename=self.file_uuid,
+            frames=frames,
+            fps=fps)
 
         # send update
         self.nm.create_file(file)
@@ -83,7 +87,7 @@ class UploadFile(View):
         """
         # pylint: disable=unused-argument
         # set relevant properties
-        self.file_name, self.file_extension = path.splitext(filename)
+        self.file_name, self.file_extension = os.path.splitext(filename)
 
         if content_length is not None and content_length > 0:
             self.file_size = content_length
@@ -91,5 +95,5 @@ class UploadFile(View):
             self.file_size = total_content_length
 
         # open file handler
-        file_path = path.join(self.data_folder, f'{self.file_id}{self.file_extension}')
+        file_path = os.path.join(self.data_folder, f'{self.file_uuid}{self.file_extension}')
         return open(file_path, 'wb')

+ 7 - 5
pycs/frontend/endpoints/jobs/RemoveJob.py

@@ -1,4 +1,6 @@
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
 from pycs.jobs.JobRunner import JobRunner
@@ -15,15 +17,15 @@ class RemoveJob(View):
         # pylint: disable=invalid-name
         self.jobs = jobs
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, job_id):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'remove' not in data or data['remove'] is not True:
-            abort(400)
+        if not data.get('remove', False):
+            abort(400, "remove flag is missing")
 
         # remove job
-        self.jobs.remove(identifier)
+        self.jobs.remove(job_id)
 
         # return success response
         return make_response()

+ 18 - 15
pycs/frontend/endpoints/labels/CreateLabel.py

@@ -1,7 +1,10 @@
-from flask import request, abort, make_response
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,30 +15,30 @@ class CreateLabel(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
-    def dispatch_request(self, identifier):
+
+    def dispatch_request(self, project_id):
         # extract request data
         data = request.get_json(force=True)
+        name = data.get('name')
 
-        if 'name' not in data:
+        if name is None:
             abort(400)
 
         name = data['name']
-        parent = data['parent'] if 'parent' in data else None
+        parent = data.get('parent')
 
         # find project
-        project = self.db.project(identifier)
-        if project is None:
-            abort(404)
-
-        # start transaction
-        with self.db:
-            # insert label
-            label, _ = project.create_label(name, parent=parent)
+        project = Project.get_or_404(project_id)
+
+        # insert label
+        with db.session.begin_nested():
+            label, is_new = project.create_label(name, parent=parent, commit=False)
+            if is_new:
+                abort(400, f"Label already exists: {label}")
 
         # send notification
         self.nm.create_label(label)

+ 14 - 16
pycs/frontend/endpoints/labels/EditLabelName.py

@@ -1,7 +1,9 @@
-from flask import request, abort, make_response
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Label import Label
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,32 +14,28 @@ class EditLabelName(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
+
     def dispatch_request(self, project_id: int, label_id: int):
         # extract request data
         data = request.get_json(force=True)
+        name = data.get('name')
 
-        if 'name' not in data:
-            abort(400)
-
-        # find project
-        project = self.db.project(project_id)
-        if project is None:
-            abort(404)
+        if name is None:
+            abort(400, 'name argument is missing')
 
         # find label
-        label = project.label(label_id)
+        label = Label.query.filter(
+            Label.project_id == project_id,
+            Label.id == label_id).one_or_none()
+
         if label is None:
             abort(404)
 
-        # start transaction
-        with self.db:
-            # change name
-            label.set_name(data['name'])
+        label.set_name(name)
 
         # send notification
         self.nm.edit_label(label)

+ 14 - 16
pycs/frontend/endpoints/labels/EditLabelParent.py

@@ -1,7 +1,9 @@
-from flask import request, abort, make_response
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Label import Label
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,32 +14,28 @@ class EditLabelParent(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
+
     def dispatch_request(self, project_id: int, label_id: int):
         # extract request data
         data = request.get_json(force=True)
+        parent = data.get('parent')
 
-        if 'parent' not in data:
-            abort(400)
-
-        # find project
-        project = self.db.project(project_id)
-        if project is None:
-            abort(404)
+        if parent is None:
+            abort(400, 'parent argument is missing')
 
         # find label
-        label = project.label(label_id)
+        label = Label.query.filter(
+            Label.project_id == project_id,
+            Label.id == label_id).one_or_none()
+
         if label is None:
             abort(404)
 
-        # start transaction
-        with self.db:
-            # change parent
-            label.set_parent(data['parent'])
+        label.set_parent(parent)
 
         # send notification
         self.nm.edit_label(label)

+ 5 - 13
pycs/frontend/endpoints/labels/ListLabelTree.py

@@ -1,7 +1,7 @@
-from flask import abort, jsonify
+from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 
 
 class ListLabelTree(View):
@@ -11,18 +11,10 @@ class ListLabelTree(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id):
         # find project
-        project = self.db.project(identifier)
-        if project is None:
-            abort(404)
-
-        # get labels
-        labels = project.label_tree()
+        project = Project.get_or_404(project_id)
 
         # return labels
-        return jsonify(labels)
+        return jsonify(project.label_tree())

+ 5 - 13
pycs/frontend/endpoints/labels/ListLabels.py

@@ -1,7 +1,7 @@
-from flask import abort, jsonify
+from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 
 
 class ListLabels(View):
@@ -11,18 +11,10 @@ class ListLabels(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id):
         # find project
-        project = self.db.project(identifier)
-        if project is None:
-            abort(404)
-
-        # get labels
-        labels = project.labels()
+        project = Project.get_or_404(project_id)
 
         # return labels
-        return jsonify(labels)
+        return jsonify(project.labels.all())

+ 23 - 22
pycs/frontend/endpoints/labels/RemoveLabel.py

@@ -1,7 +1,10 @@
-from flask import request, abort, make_response
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
+from pycs.database.Label import Label
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,41 +15,39 @@ class RemoveLabel(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
     def dispatch_request(self, project_id: int, label_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'remove' not in data or data['remove'] is not True:
-            abort(400)
-
-        # find project
-        project = self.db.project(project_id)
-        if project is None:
-            abort(404)
+        if not data.get('remove', False):
+            abort(400, "remove flag is missing")
 
         # find label
-        label = project.label(label_id)
+        label = Label.query.filter(
+            Label.project_id == project_id,
+            Label.id == label_id).one_or_none()
+
         if label is None:
             abort(404)
 
-        # find children
-        children = label.children()
-
         # start transaction
-        with self.db:
-            # remove children's parent entry
-            for child in children:
-                child.set_parent(None)
-                self.nm.edit_label(child)
+        with db.session.begin_nested():
+
+            # update children's parent entry
+            label.children.update({Label.parent: label.parent},
+                synchronize_session=False)
 
             # remove label
-            label.remove()
-            self.nm.remove_label(label)
+            label_dump = label.delete(commit=False)
+
+            # notify about changes
+            for child in children:
+                self.nm.edit_label(child)
+            self.nm.remove_label(label_dump)
 
         # return success response
         return make_response()

+ 11 - 14
pycs/frontend/endpoints/pipelines/FitModel.py

@@ -1,7 +1,7 @@
 from flask import make_response, request, abort
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.interfaces.MediaStorage import MediaStorage
 from pycs.jobs.JobGroupBusyException import JobGroupBusyException
 from pycs.jobs.JobRunner import JobRunner
@@ -15,9 +15,8 @@ class FitModel(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, jobs: JobRunner, pipelines: PipelineCache):
+    def __init__(self, jobs: JobRunner, pipelines: PipelineCache):
         # pylint: disable=invalid-name
-        self.db = db
         self.jobs = jobs
         self.pipelines = pipelines
 
@@ -25,13 +24,11 @@ class FitModel(View):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'fit' not in data or data['fit'] is not True:
-            return abort(400)
+        if not data.get('fit', False):
+            abort(400, "fit flag is missing")
 
         # find project
-        project = self.db.project(project_id)
-        if project is None:
-            return abort(404)
+        project = Project.get_or_404(project_id)
 
         # create job
         try:
@@ -39,18 +36,18 @@ class FitModel(View):
                           'Model Interaction',
                           f'{project.name} (fit model with new data)',
                           f'{project.name}/model-interaction',
-                          self.load_and_fit, self.db, project.identifier)
+                          FitModel.load_and_fit, project.id)
+
         except JobGroupBusyException:
-            return abort(400)
+            return abort(400, "Model fitting already running")
 
         return make_response()
 
     @staticmethod
-    def load_and_fit(database: Database, pipelines: PipelineCache, project_id: int):
+    def load_and_fit(pipelines: PipelineCache, project_id: int):
         """
         load the pipeline and call the fit function
 
-        :param database: database object
         :param pipelines: pipeline cache
         :param project_id: project id
         """
@@ -60,8 +57,8 @@ class FitModel(View):
         # create new database instance
         try:
             database_copy = database.copy()
-            project = database_copy.project(project_id)
-            model = project.model()
+            project = Project.query.get(project_id)
+            model = project.model
             storage = MediaStorage(database_copy, project_id)
 
             # load pipeline

+ 9 - 13
pycs/frontend/endpoints/pipelines/PredictFile.py

@@ -1,7 +1,6 @@
 from flask import make_response, request, abort
 from flask.views import View
 
-from pycs.database.Database import Database
 from pycs.frontend.endpoints.pipelines.PredictModel import PredictModel as Predict
 from pycs.frontend.notifications.NotificationList import NotificationList
 from pycs.frontend.notifications.NotificationManager import NotificationManager
@@ -17,10 +16,8 @@ class PredictFile(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self,
-                 db: Database, nm: NotificationManager, jobs: JobRunner, pipelines: PipelineCache):
+    def __init__(self, nm: NotificationManager, jobs: JobRunner, pipelines: PipelineCache):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
         self.jobs = jobs
         self.pipelines = pipelines
@@ -29,16 +26,14 @@ class PredictFile(View):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'predict' not in data or data['predict'] is not True:
-            return abort(400)
+        if not data.get('predict', False):
+            abort(400, "predict flag is missing")
 
         # find file
-        file = self.db.file(file_id)
-        if file is None:
-            return abort(404)
+        file = File.get_or_404(file_id)
 
         # get project and model
-        project = file.project()
+        project = file.project
 
         # create job
         try:
@@ -47,11 +42,12 @@ class PredictFile(View):
             self.jobs.run(project,
                           'Model Interaction',
                           f'{project.name} (create predictions)',
-                          f'{project.name}/model-interaction',
+                          f'{project.id}/model-interaction',
                           Predict.load_and_predict,
-                          self.db, self.pipelines, notifications, project.identifier, [file],
+                          self.pipelines, notifications, project.id, [file.id],
                           progress=Predict.progress)
+
         except JobGroupBusyException:
-            return abort(400)
+            abort(400, "File prediction is already running")
 
         return make_response()

+ 66 - 63
pycs/frontend/endpoints/pipelines/PredictModel.py

@@ -1,10 +1,14 @@
-from typing import Union, List
+from typing import List
+from typing import Union
 
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
 from pycs.database.File import File
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationList import NotificationList
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.interfaces.MediaFile import MediaFile
@@ -21,10 +25,8 @@ class PredictModel(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self,
-                 db: Database, nm: NotificationManager, jobs: JobRunner, pipelines: PipelineCache):
+    def __init__(self, nm: NotificationManager, jobs: JobRunner, pipelines: PipelineCache):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
         self.jobs = jobs
         self.pipelines = pipelines
@@ -33,13 +35,16 @@ class PredictModel(View):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'predict' not in data or data['predict'] not in ['all', 'new']:
-            return abort(400)
+        predict = data.get('predict')
+
+        if predict is None:
+            abort(400, "predict argument is missing")
+
+        if predict not in ['all', 'new']:
+            abort(400, "predict must be either 'all' or 'new'")
 
         # find project
-        project = self.db.project(project_id)
-        if project is None:
-            return abort(404)
+        project = Project.get_or_404(project_id)
 
         # create job
         try:
@@ -48,20 +53,21 @@ class PredictModel(View):
             self.jobs.run(project,
                           'Model Interaction',
                           f'{project.name} (create predictions)',
-                          f'{project.name}/model-interaction',
-                          self.load_and_predict,
-                          self.db, self.pipelines, notifications,
-                          project.identifier, data['predict'],
+                          f'{project.id}/model-interaction',
+                          PredictModel.load_and_predict,
+                          self.pipelines, notifications,
+                          project.id, predict,
                           progress=self.progress)
+
         except JobGroupBusyException:
-            return abort(400)
+            abort(400, "Model prediction is already running")
 
         return make_response()
 
     @staticmethod
-    def load_and_predict(database: Database, pipelines: PipelineCache,
+    def load_and_predict(pipelines: PipelineCache,
                          notifications: NotificationList,
-                         project_id: int, file_filter: Union[str, List[File]]):
+                         project_id: int, file_filter: Union[str, List[int]]):
         """
         load the pipeline and call the execute function
 
@@ -69,58 +75,55 @@ class PredictModel(View):
         :param pipelines: pipeline cache
         :param notifications: notification object
         :param project_id: project id
-        :param file_filter: list of files or 'new' / 'all'
+        :param file_filter: list of file ids or 'new' / 'all'
         :return:
         """
-        database_copy = None
         pipeline = None
 
         # create new database instance
-        try:
-            database_copy = database.copy()
-            project = database_copy.project(project_id)
-            model = project.model()
-            storage = MediaStorage(database_copy, project_id, notifications)
-
-            # create a list of MediaFile
-            if isinstance(file_filter, str):
-                if file_filter == 'new':
-                    length = project.count_files_without_results()
-                    files = map(lambda f: MediaFile(f, notifications),
-                                project.files_without_results())
-                else:
-                    length = project.count_files()
-                    files = map(lambda f: MediaFile(f, notifications),
-                                project.files())
+        project = Project.query.get(project_id)
+        model = project.model
+        storage = MediaStorage(project_id, notifications)
+
+        # create a list of MediaFile
+        if isinstance(file_filter, str):
+            if file_filter == 'new':
+                files = project.files_without_results()
+                length = project.count_files_without_results()
+
             else:
-                files = map(lambda f: MediaFile(project.file(f.identifier), notifications),
-                            file_filter)
-                length = len(file_filter)
-
-            # load pipeline
-            try:
-                pipeline = pipelines.load_from_root_folder(project, model.root_folder)
-
-                # iterate over files
-                index = 0
-                for file in files:
-                    # remove old predictions
-                    file.remove_predictions()
-
-                    # create new predictions
-                    pipeline.execute(storage, file)
-
-                    # commit changes and yield progress
-                    database_copy.commit()
-                    yield index / length, notifications
-
-                    index += 1
-            finally:
-                if pipeline is not None:
-                    pipelines.free_instance(model.root_folder)
+                files = project.files.all()
+                length = project.files.count()
+
+        else:
+            files = [project.file(identifier) for identifier in file_filter]
+            length = len(files)
+
+
+        media_files = map(lambda f: MediaFile(f, notifications), files)
+        # load pipeline
+        try:
+            pipeline = pipelines.load_from_root_folder(project, model.root_folder)
+
+            # iterate over media files
+            index = 0
+            for file in media_files:
+                # remove old predictions
+                file.remove_predictions()
+
+                # create new predictions
+                pipeline.execute(storage, file)
+
+                # commit changes and yield progress
+                db.session.commit()
+                yield index / length, notifications
+
+                index += 1
+
         finally:
-            if database_copy is not None:
-                database_copy.close()
+            if pipeline is not None:
+                pipelines.free_instance(model.root_folder)
+
 
     @staticmethod
     def progress(progress: float, notifications: NotificationList):

+ 73 - 64
pycs/frontend/endpoints/projects/CreateProject.py

@@ -1,13 +1,20 @@
+import os
+import shutil
+import uuid
+
 from contextlib import closing
-from os import mkdir
-from os import path
-from shutil import copytree
-from uuid import uuid1
+from pathlib import Path
 
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
+from pycs import settings
+from pycs.database.LabelProvider import LabelProvider
+from pycs.database.Model import Model
+from pycs.database.Project import Project
 from pycs.frontend.endpoints.projects.ExecuteExternalStorage import ExecuteExternalStorage
 from pycs.frontend.endpoints.projects.ExecuteLabelProvider import ExecuteLabelProvider
 from pycs.frontend.notifications.NotificationManager import NotificationManager
@@ -22,9 +29,8 @@ class CreateProject(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+    def __init__(self, nm: NotificationManager, jobs: JobRunner):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
         self.jobs = jobs
 
@@ -32,65 +38,70 @@ class CreateProject(View):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'name' not in data or 'description' not in data:
-            return abort(400)
+        name = data.get('name')
+        description = data.get('description')
+
+        if name is None:
+            abort(400, "name argument is missing!")
 
-        name = data['name']
-        description = data['description']
+        if description is None:
+            abort(400, "description argument is missing!")
 
-        # start transaction
-        with self.db:
-            # find model
-            model_id = int(data['model'])
-            model = self.db.model(model_id)
 
-            if model is None:
-                return abort(404)
+        model_id = int(data['model'])
+        model = Model.get_or_404(model_id)
 
-            # find label provider
-            if data['label'] is None:
-                label_provider = None
-            else:
-                label_provider_id = int(data['label'])
-                label_provider = self.db.label_provider(label_provider_id)
+        label_provider_id = data.get('label')
+        label_provider = None
 
-                if label_provider is None:
-                    return abort(404)
+        if label_provider_id is not None:
+            label_provider = LabelProvider.get_or_404(label_provider_id)
 
-            # create project folder
-            project_folder = path.join('projects', str(uuid1()))
-            mkdir(project_folder)
+        # create project folder
+        project_folder = Path(settings.projects_folder, str(uuid.uuid1()))
+        project_folder.mkdir()
 
-            temp_folder = path.join(project_folder, 'temp')
-            mkdir(temp_folder)
+        temp_folder = project_folder / 'temp'
+        temp_folder.mkdir()
 
-            # check project data directory
-            if data['external'] is None:
-                external_data = False
-                data_folder = path.join(project_folder, 'data')
+        # check project data directory
+        if data['external'] is None:
+            external_data = False
+            data_folder = project_folder / 'data'
+            data_folder.mkdir()
 
-                mkdir(data_folder)
-            else:
-                external_data = True
-                data_folder = data['external']
+        else:
+            external_data = True
+            data_folder = Path(data['external'])
 
-                # check if exists
-                if not path.exists(data_folder):
-                    return abort(400)
+            # check if exists
+            if not data_folder.exists():
+                return abort(400, f"External folder does not exist: {data_folder}")
 
-            # copy model to project folder
-            model_folder = path.join(project_folder, 'model')
-            copytree(model.root_folder, model_folder)
+        # copy model to project folder
+        model_folder = project_folder / 'model'
+        shutil.copytree(model.root_folder, str(model_folder))
 
-            model, _ = model.copy_to(f'{model.name} ({name})', model_folder)
+        with db.session.begin_nested():
+            model, is_new = model.copy_to(
+                name=f'{model.name} ({name})',
+                root_folder=str(model_folder),
+                commit=False)
 
-            # create entry in database
-            created = self.db.create_project(name, description, model, label_provider,
-                                             project_folder, external_data, data_folder)
+            if not is_new:
+                abort(400, f"Could not copy model! Model in \"{model_folder}\" already exists!")
+
+            project = Project.new(name=name,
+                                  description=description,
+                                  model_id=model_id,
+                                  label_provider_id=label_provider_id,
+                                  root_folder=str(project_folder),
+                                  external_data=external_data,
+                                  data_folder=str(data_folder))
 
         # execute label provider and add labels to project
         if label_provider is not None:
-            ExecuteLabelProvider.execute_label_provider(self.db, self.nm, self.jobs, created,
+            ExecuteLabelProvider.execute_label_provider(self.nm, self.jobs, project,
                                                         label_provider)
 
         # load model and add collections to the project
@@ -99,28 +110,26 @@ class CreateProject(View):
                 return pipeline.collections()
 
         def add_collections_to_project(provided_collections):
-            with self.db:
-                for position, collection in enumerate(provided_collections):
-                    created.create_collection(collection['reference'],
-                                              collection['name'],
-                                              collection['description'],
-                                              position + 1,
-                                              collection['autoselect'])
-
-        self.jobs.run(created,
+            with db.session.begin_nested():
+                for position, collection in enumerate(provided_collections, 1):
+                    project.create_collection(commit=False,
+                                              position=position,
+                                              **collection)
+
+        self.jobs.run(project,
                       'Media Collections',
-                      f'{created.name}',
-                      f'{created.identifier}/media-collections',
+                      f'{project.name}',
+                      f'{project.id}/media-collections',
                       executable=load_model_and_get_collections,
                       result=add_collections_to_project)
 
         # find media files
         if external_data:
-            ExecuteExternalStorage.find_media_files(self.db, self.nm, self.jobs, created)
+            ExecuteExternalStorage.find_media_files(self.nm, self.jobs, project)
 
         # fire event
         self.nm.create_model(model)
-        self.nm.create_project(created)
+        self.nm.create_project(project)
 
         # return success response
         return make_response()

+ 16 - 17
pycs/frontend/endpoints/projects/EditProjectDescription.py

@@ -1,7 +1,9 @@
-from flask import make_response, abort
-from flask.views import View, request
+from flask import abort
+from flask import make_response
+from flask.views import View
+from flask.views import request
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,27 +14,24 @@ class EditProjectDescription(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
-    def dispatch_request(self, identifier):
+
+    def dispatch_request(self, project_id: int):
         # extract request data
         data = request.get_json(force=True)
+        description = data.get('description')
+
+        if description is None:
+            abort(400, 'description argument is missing')
 
-        if 'description' not in data or not data['description']:
-            return abort(400)
+        project = Project.get_or_404(project_id)
 
-        # start transaction
-        with self.db:
-            # find project
-            project = self.db.project(identifier)
-            if project is None:
-                return abort(404)
+        project.description = description
+        project.commit()
 
-            # set description
-            project.set_description(data['description'])
-            self.nm.edit_project(project)
+        self.nm.edit_project(project)
 
         return make_response()

+ 17 - 18
pycs/frontend/endpoints/projects/EditProjectName.py

@@ -1,7 +1,9 @@
-from flask import make_response, abort
-from flask.views import View, request
+from flask import abort
+from flask import make_response
+from flask.views import View
+from flask.views import request
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,27 +14,24 @@ class EditProjectName(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
-    def dispatch_request(self, identifier):
+
+    def dispatch_request(self, project_id: int):
         # extract request data
         data = request.get_json(force=True)
+        name = data.get('name')
+
+        if name is None:
+            abort(400, 'name argument is missing')
 
-        if 'name' not in data or not data['name']:
-            return abort(400)
+        project = Project.get_or_404(project_id)
 
-        # start transaction
-        with self.db:
-            # find project
-            project = self.db.project(identifier)
-            if project is None:
-                return abort(404)
+        project.name = name
+        project.commit()
 
-            # set name
-            project.set_name(data['name'])
-            self.nm.edit_project(project)
+        self.nm.edit_project(project)
 
-            return make_response()
+        return make_response()

+ 37 - 34
pycs/frontend/endpoints/projects/ExecuteExternalStorage.py

@@ -1,12 +1,12 @@
-from os import listdir
-from os import path
-from os.path import isfile
-from uuid import uuid1
+import os
+import uuid
 
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
 from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.jobs.JobGroupBusyException import JobGroupBusyException
@@ -21,42 +21,38 @@ class ExecuteExternalStorage(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+    def __init__(self, nm: NotificationManager, jobs: JobRunner):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
         self.jobs = jobs
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'execute' not in data or data['execute'] is not True:
+        if not data.get('execute', False):
             return abort(400)
 
         # find project
-        project = self.db.project(identifier)
-        if project is None:
-            return abort(404)
+        project = Project.get_or_404(project_id)
 
         if not project.external_data:
-            return abort(400)
+            return abort(400, "External data is not set!")
 
         # execute label provider and add labels to project
         try:
-            self.find_media_files(self.db, self.nm, self.jobs, project)
+            ExecuteExternalStorage.find_media_files(self.nm, self.jobs, project)
         except JobGroupBusyException:
-            return abort(400)
+            return abort(400, "Job is already running!")
 
         return make_response()
 
     @staticmethod
-    def find_media_files(db: Database, nm: NotificationManager, jobs: JobRunner, project: Project):
+    def find_media_files(nm: NotificationManager, jobs: JobRunner, project: Project):
         """
         start a job that finds media files in the projects data_folder and adds them to the
         database afterwards
 
-        :param db: database object
         :param nm: notification manager object
         :param jobs: job runner object
         :param project: project
@@ -65,27 +61,36 @@ class ExecuteExternalStorage(View):
 
         # pylint: disable=invalid-name
         # find lists the given data folder and prepares item dictionaries
-        def find():
-            files = listdir(project.data_folder)
+        def find(data_folder):
+            files = os.listdir(data_folder)
             length = len(files)
 
             elements = []
             current = 0
 
             for file_name in files:
-                file_path = path.join(project.data_folder, file_name)
-                if not isfile(file_path):
+                file_path = os.path.join(data_folder, file_name)
+                if not os.path.isfile(file_path):
                     continue
 
-                file_name, file_extension = path.splitext(file_name)
-                file_size = path.getsize(file_path)
+                file_name, file_extension = os.path.splitext(file_name)
+                file_size = os.path.getsize(file_path)
 
                 try:
-                    ftype, frames, fps = file_info(project.data_folder, file_name, file_extension)
+                    ftype, frames, fps = file_info(data_folder, file_name, file_extension)
                 except ValueError:
                     continue
 
-                elements.append((ftype, file_name, file_extension, file_size, frames, fps))
+                file_attrs = dict(
+                    uuid=str(uuid.uuid1()),
+                    file_type=ftype,
+                    name=file_name,
+                    extension=file_extension,
+                    size=file_size,
+                    frames=frames,
+                    fps=fps)
+
+                elements.append(file_attrs)
                 current += 1
 
                 if len(elements) >= 200:
@@ -97,13 +102,11 @@ class ExecuteExternalStorage(View):
 
         # progress inserts elements into the database and fires events
         def progress(elements, current, length):
-            with db:
-                for ftype, file_name, file_extension, file_size, frames, fps in elements:
-                    uuid = str(uuid1())
-                    file, insert = project.add_file(uuid, ftype, file_name, file_extension,
-                                                    file_size, file_name, frames, fps)
+            with db.session.begin_nested():
+                for file_attrs in elements:
+                    file, is_new = project.add_file(commit=False, **file_attrs)
 
-                    if insert:
+                    if is_new:
                         nm.create_file(file)
 
             return current / length
@@ -112,6 +115,6 @@ class ExecuteExternalStorage(View):
         jobs.run(project,
                  'Find Media Files',
                  project.name,
-                 f'{project.identifier}/find-files',
-                 find,
+                 f'{project.id}/find-files',
+                 find, project.data_folder,
                  progress=progress)

+ 20 - 24
pycs/frontend/endpoints/projects/ExecuteLabelProvider.py

@@ -1,9 +1,11 @@
 from contextlib import closing
 
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs import db
 from pycs.database.LabelProvider import LabelProvider
 from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
@@ -18,45 +20,41 @@ class ExecuteLabelProvider(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+    def __init__(self, nm: NotificationManager, jobs: JobRunner):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
         self.jobs = jobs
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'execute' not in data or data['execute'] is not True:
-            return abort(400)
+        if not data.get('execute', False):
+            abort(400, "execute flag is missing")
 
         # find project
-        project = self.db.project(identifier)
-        if project is None:
-            return abort(404)
+        project = Project.get_or_404(project_id)
 
         # get label provider
-        label_provider = project.label_provider()
+        label_provider = project.label_provider
         if label_provider is None:
-            return abort(400)
+            abort(400, "This project does not have a label provider.")
 
         # execute label provider and add labels to project
         try:
-            self.execute_label_provider(self.db, self.nm, self.jobs, project, label_provider)
+            self.execute_label_provider(self.nm, self.jobs, project, label_provider)
         except JobGroupBusyException:
-            return abort(400)
+            abort(400, "Label provider already running.")
 
         return make_response()
 
     @staticmethod
-    def execute_label_provider(db: Database, nm: NotificationManager, jobs: JobRunner,
+    def execute_label_provider(nm: NotificationManager, jobs: JobRunner,
                                project: Project, label_provider: LabelProvider):
         """
         start a job that loads and executes a label provider and saves its results to the
         database afterwards
 
-        :param db: database object
         :param nm: notification manager object
         :param jobs: job runner object
         :param project: project
@@ -68,18 +66,16 @@ class ExecuteLabelProvider(View):
         # receive loads and executes the given label provider
         def receive():
             with closing(label_provider.load()) as label_provider_impl:
-                provided_labels = label_provider_impl.get_labels()
-                return provided_labels
+                return label_provider_impl.get_labels()
 
         # result adds the received labels to the database and fires events
         def result(provided_labels):
-            with db:
+            with db.session.begin_nested():
                 for label in provided_labels:
-                    created_label, insert = project.create_label(
-                        label['name'], label['reference'], label['parent'], label['hierarchy_level']
-                    )
+                    created_label, is_new = project.create_label(commit=False, **label)
+                    project.flush()
 
-                    if insert:
+                    if is_new:
                         nm.create_label(created_label)
                     else:
                         nm.edit_label(created_label)
@@ -88,6 +84,6 @@ class ExecuteLabelProvider(View):
         jobs.run(project,
                  'Label Provider',
                  f'{project.name} ({label_provider.name})',
-                 f'{project.identifier}/label-provider',
+                 f'{project.id}/label-provider',
                  receive,
                  result=result)

+ 5 - 13
pycs/frontend/endpoints/projects/GetProjectModel.py

@@ -1,7 +1,7 @@
-from flask import abort, jsonify
+from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 
 
 class GetProjectModel(View):
@@ -11,18 +11,10 @@ class GetProjectModel(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id: int):
         # find project
-        project = self.db.project(identifier)
-        if project is None:
-            abort(404)
-
-        # get model
-        model = project.model()
+        project = Project.get_or_404(project_id)
 
         # return model
-        return jsonify(model)
+        return jsonify(project.model)

+ 0 - 40
pycs/frontend/endpoints/projects/ListCollections.py

@@ -1,40 +0,0 @@
-from flask import abort, jsonify
-from flask.views import View
-
-from pycs.database.Database import Database
-
-
-class ListCollections(View):
-    """
-    return a list of collections for a given project
-    """
-    # pylint: disable=arguments-differ
-    methods = ['GET']
-
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
-
-    def dispatch_request(self, project_id: int):
-        # find project
-        project = self.db.project(project_id)
-
-        if project is None:
-            return abort(404)
-
-        # get collection list
-        collections = project.collections()
-
-        # disable autoselect if there are no elements in the collection
-        found = False
-
-        for collection in collections:
-            if collection.autoselect:
-                if found:
-                    collection.autoselect = False
-                elif collection.count_files() == 0:
-                    collection.autoselect = False
-                    found = True
-
-        # return files
-        return jsonify(collections)

+ 24 - 0
pycs/frontend/endpoints/projects/ListProjectCollections.py

@@ -0,0 +1,24 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Project import Project
+from pycs.database.Collection import Collection
+
+
+class ListProjectCollections(View):
+    """
+    return a list of collections for a given project
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+
+    def dispatch_request(self, project_id: int):
+        # find project
+        project = Project.get_or_404(project_id)
+
+        # get collection list
+        collections = Collection.update_autoselect(project.collections)
+
+        # return files
+        return jsonify(collections)

+ 14 - 15
pycs/frontend/endpoints/projects/ListFiles.py → pycs/frontend/endpoints/projects/ListProjectFiles.py

@@ -1,41 +1,40 @@
-from flask import abort, jsonify
+from flask import abort
+from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 
 
-class ListFiles(View):
+class ListProjectFiles(View):
     """
     return a list of files for a given project
     """
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self, project_id: int, start: int, length: int, collection_id: int = None):
         # find project
-        project = self.db.project(project_id)
-        if project is None:
-            return abort(404)
+
+        project = Project.get_or_404(project_id)
 
         # get count and files
         if collection_id is not None:
             if collection_id == 0:
                 count = project.count_files_without_collection()
-                files = list(project.files_without_collection(start, length))
+                files = project.files_without_collection(start, length)
+
             else:
                 collection = project.collection(collection_id)
                 if collection is None:
-                    return abort(404)
+                    abort(404)
+
+                count = collection.files.count()
+                files = collection.get_files(start, length).all()
 
-                count = collection.count_files()
-                files = list(collection.files(start, length))
         else:
-            count = project.count_files()
-            files = list(project.files(start, length))
+            count = project.files.count()
+            files = project.get_files(start, length).all()
 
         # return files
         return jsonify({

+ 16 - 28
pycs/frontend/endpoints/projects/RemoveProject.py

@@ -1,9 +1,9 @@
-from shutil import rmtree
-
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -14,37 +14,25 @@ class RemoveProject(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self,nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
-    def dispatch_request(self, identifier):
+    def dispatch_request(self, project_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'remove' not in data or data['remove'] is not True:
-            abort(400)
-
-        # start transaction
-        with self.db:
-            # find project
-            project = self.db.project(identifier)
-            if project is None:
-                abort(404)
-
-            # remove from database
-            project.remove()
+        if not data.get('remove', False):
+            abort(400, "remove flag is missing")
 
-            # remove model from database
-            model = project.model()
-            model.remove()
+        # find project
+        project = Project.get_or_404(project_id)
 
-            # remove from file system
-            rmtree(project.root_folder)
+        # remove from database
+        project_dump, model_dump = project.delete()
 
-            # send update
-            self.nm.remove_model(model)
-            self.nm.remove_project(project)
+        # send update
+        self.nm.remove_model(model_dump)
+        self.nm.remove_project(project_dump)
 
-            return make_response()
+        return make_response()

+ 6 - 11
pycs/frontend/endpoints/results/ConfirmResult.py

@@ -1,7 +1,7 @@
 from flask import make_response, request, abort
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Result import Result
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,26 +12,21 @@ class ConfirmResult(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
     def dispatch_request(self, result_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'confirm' not in data or data['confirm'] is not True:
-            return abort(400)
+        if not data.get('confirm', False):
+            return abort(400, "confirm flag is missing")
 
         # find result
-        result = self.db.result(result_id)
-        if result is None:
-            return abort(404)
+        result = Result.get_or_404(result_id)
 
-        # start transaction
-        with self.db:
-            result.set_origin('user')
+        result.set_origin('user')
 
         self.nm.edit_result(result)
         return make_response()

+ 43 - 33
pycs/frontend/endpoints/results/CreateResult.py

@@ -1,7 +1,7 @@
 from flask import request, abort, jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.File import File
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,51 +12,61 @@ class CreateResult(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
-    def dispatch_request(self, file_id: int):
-        # extract request data
+    def extract_request_data(self):
         request_data = request.get_json(force=True)
 
         if 'type' not in request_data:
-            return abort(400)
-        if request_data['type'] not in ['labeled-image', 'bounding-box']:
-            return abort(400)
-
-        rtype = request_data['type']
-
-        if 'label' in request_data and request_data['label']:
-            label = request_data['label']
-        elif request_data['type'] == 'labeled-image':
-            return abort(400)
-        else:
-            label = None
-
-        if 'data' in request_data and request_data['data']:
-            data = request_data['data']
-        elif request_data['type'] == 'bounding-box':
-            return abort(400)
-        else:
-            data = {}
+            abort(400, "result type argument is missing")
+
+        result_type = request_data.get('type')
+        if result_type not in ['labeled-image', 'bounding-box']:
+            abort(400, "result type must be either 'labeled-image' or 'bounding-box'")
+
+        label = None
+        data = {}
+
+        if result_type == 'labeled-image':
+            label = request_data.get('label')
+            if label is None:
+                abort(400, f"Could not find label argument ({result_type=})")
+
+        elif result_type == 'bounding-box':
+            data = request_data.get('data')
+            if data is None:
+                abort(400, f"Could not find data argument ({result_type=})")
+
+        return result_type, label, data
+
+
+    def dispatch_request(self, file_id: int):
+
+        result_type, label, data = self.extract_request_data()
 
         # find file
-        file = self.db.file(file_id)
-        if file is None:
-            return abort(404)
+        file = File.get_or_404(file_id)
 
         # start transaction
-        with self.db:
+        with db.session.begin_nested():
             # find full-image labels and remove them
-            for result in file.results():
-                if result.type == 'labeled-image':
-                    result.remove()
-                    self.nm.remove_result(result)
+            image_results = file.results.filter_by(type='labeled-image')
+
+            removed = [result.serialize() for result in image_results.all()]
+            image_results.delete()
+
+            for result in removed:
+                self.nm.remove_result(result)
 
             # insert into database
-            result = file.create_result('user', rtype, label, data)
+            result = file.create_result(
+                origin='user',
+                result_type=result_type,
+                label=label,
+                data=data)
+
             self.nm.create_result(result)
 
         return jsonify(result)

+ 14 - 16
pycs/frontend/endpoints/results/EditResultData.py

@@ -1,7 +1,9 @@
-from flask import make_response, abort
-from flask.views import View, request
+from flask import abort
+from flask import make_response
+from flask.views import View
+from flask.views import request
 
-from pycs.database.Database import Database
+from pycs.database.Result import Result
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,27 +14,23 @@ class EditResultData(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
     def dispatch_request(self, result_id: int):
         # extract request data
-        data = request.get_json(force=True)
+        request_data = request.get_json(force=True)
+        data = request_data.get('data')
 
-        if 'data' not in data:
-            return abort(400)
+        if data is None:
+            abort(400, "Could not find data argument!")
 
         # find result
-        result = self.db.result(result_id)
-        if result is None:
-            return abort(404)
-
-        # start transaction and set label
-        with self.db:
-            result.set_data(data['data'])
-            result.set_origin('user')
+        result = Result.get_or_404(result_id)
+
+        result.data = data
+        result.set_origin('user', commit=True)
 
         self.nm.edit_result(result)
         return make_response()

+ 15 - 17
pycs/frontend/endpoints/results/EditResultLabel.py

@@ -1,7 +1,9 @@
-from flask import make_response, abort
-from flask.views import View, request
+from flask import abort
+from flask import make_response
+from flask.views import View
+from flask.views import request
 
-from pycs.database.Database import Database
+from pycs.database.Result import Result
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,31 +14,27 @@ class EditResultLabel(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
     def dispatch_request(self, result_id: int):
         # extract request data
-        data = request.get_json(force=True)
+        request_data = request.get_json(force=True)
 
-        if 'label' not in data:
-            return abort(400)
+        if 'label' not in request_data:
+            abort(400, "Could not find label argument!")
 
         # find result
-        result = self.db.result(result_id)
-        if result is None:
-            return abort(404)
+        result = Result.get_or_404(result_id)
+        label = request_data.get('label')
 
         # abort if label is empty for labeled-images
-        if result.type == 'labeled-image' and not data['label']:
-            return abort(400)
+        if result.type == 'labeled-image' and label is None:
+            return abort(400, "Label is required for 'labeled-images' results")
 
-        # start transaction and set label
-        with self.db:
-            result.set_label(data['label'])
-            result.set_origin('user')
+        result.label = label
+        result.set_origin('user', commit=True)
 
         self.nm.edit_result(result)
         return make_response()

+ 5 - 9
pycs/frontend/endpoints/results/GetProjectResults.py

@@ -1,7 +1,8 @@
-from flask import abort, jsonify
+from flask import abort
+from flask import jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.interfaces.MediaStorage import MediaStorage
 
 
@@ -12,18 +13,13 @@ class GetProjectResults(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self, project_id: int):
         # get project from database
-        project = self.db.project(project_id)
-        if project is None:
-            return abort(404)
+        project = Project.get_or_404(project_id)
 
         # map media files to a dict
-        storage = MediaStorage(self.db, project.identifier, None)
+        storage = MediaStorage(project, None)
         files = list(map(lambda f: f.serialize(), storage.files().iter()))
 
         # return result

+ 3 - 11
pycs/frontend/endpoints/results/GetResults.py

@@ -1,7 +1,7 @@
 from flask import abort, jsonify
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.File import File
 
 
 class GetResults(View):
@@ -11,18 +11,10 @@ class GetResults(View):
     # pylint: disable=arguments-differ
     methods = ['GET']
 
-    def __init__(self, db: Database):
-        # pylint: disable=invalid-name
-        self.db = db
 
     def dispatch_request(self, file_id: int):
         # get file from database
-        file = self.db.file(file_id)
-        if file is None:
-            return abort(404)
-
-        # get results
-        results = file.results()
+        file = File.get_or_404(file_id)
 
         # return result
-        return jsonify(results)
+        return jsonify(file.results.all())

+ 10 - 13
pycs/frontend/endpoints/results/RemoveResult.py

@@ -1,7 +1,9 @@
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 
-from pycs.database.Database import Database
+from pycs.database.Result import Result
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,26 +14,21 @@ class RemoveResult(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
     def dispatch_request(self, result_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'remove' not in data or data['remove'] is not True:
-            abort(400)
+        if not data.get('remove', False):
+            abort(400, "remove flag is missing")
 
         # find result
-        result = self.db.result(result_id)
-        if result is None:
-            return abort(404)
+        result = Result.get_or_404(result_id)
 
-        # start transaction
-        with self.db:
-            result.remove()
+        dump = result.delete()
 
-        self.nm.remove_result(result)
+        self.nm.remove_result(dump)
         return make_response()

+ 14 - 16
pycs/frontend/endpoints/results/ResetResults.py

@@ -1,7 +1,9 @@
-from flask import make_response, abort
-from flask.views import View, request
+from flask import abort
+from flask import make_response
+from flask.views import View
+from flask.views import request
 
-from pycs.database.Database import Database
+from pycs.database.File import File
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
@@ -12,32 +14,28 @@ class ResetResults(View):
     # pylint: disable=arguments-differ
     methods = ['POST']
 
-    def __init__(self, db: Database, nm: NotificationManager):
+    def __init__(self, nm: NotificationManager):
         # pylint: disable=invalid-name
-        self.db = db
         self.nm = nm
 
     def dispatch_request(self, file_id: int):
         # extract request data
         data = request.get_json(force=True)
 
-        if 'reset' not in data or data['reset'] is not True:
+        if not data.get('reset', False):
             abort(400)
 
         # find file
-        file = self.db.file(file_id)
-        if file is None:
-            return abort(404)
+        file = File.get_or_404(file_id)
 
-        # get results
-        results = file.results()
+        removed = []
 
-        # start transaction
-        with self.db:
-            for result in results:
-                result.remove()
+        for result in file.results.all():
+            removed.append(result.serialize())
 
-        for result in results:
+        file.results.delete()
+
+        for result in removed:
             self.nm.remove_result(result)
 
         return make_response()

+ 60 - 0
pycs/frontend/endpoints/results/ResultAsCrop.py

@@ -0,0 +1,60 @@
+import os
+
+from flask import abort
+from flask import send_from_directory
+from flask.views import View
+
+from pycs.database.Result import Result
+from pycs.util import file_ops
+
+
+class ResultAsCrop(View):
+    """
+    return the image crop defined by the result.
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+
+    def dispatch_request(self, result_id: int, max_width: int = 2**24, max_height: int = 2**24):
+
+        # find result
+        result = Result.get_or_404(result_id)
+
+        if result.type != "bounding-box":
+            abort(400, f"The type of the queried result was not \"bounding-box\"! It was {result.type}")
+
+        file = result.file
+
+        if file.type != "image":
+            abort(400, f"Currently only supporting images!")
+
+
+        data = result.data
+
+        if data is None:
+            abort(400, "The data of the result was None!")
+
+        xywh = [data.get(attr, -1) for attr in "xywh"]
+        if -1 in xywh:
+            abort(400, f"The data of the result is not correct: {data}!")
+
+        x, y, w, h = xywh
+
+        crop_path, crop_fname = file_ops.crop_file(file, file.project.root_folder, x, y, w, h)
+
+        parts = os.path.splitext(crop_fname)
+
+        crop_new_fname = f"{parts[0]}_{max_width}_{max_height}.{parts[1]}"
+
+        resized = file_ops.resize_image(
+            os.path.join(crop_path, crop_fname),
+            os.path.join(crop_path, crop_new_fname),
+            max_width,
+            max_height
+        )
+
+        if resized:
+            crop_fname = crop_new_fname
+
+        return send_from_directory(crop_path, crop_fname)

+ 14 - 6
pycs/frontend/util/JSONEncoder.py

@@ -1,22 +1,30 @@
-from typing import Any
+import datetime
 
 from flask.json import JSONEncoder as Base
+from typing import Any
 
-from pycs.database.util.JSONEncoder import JSONEncoder as Database
-from pycs.jobs.util.JSONEncoder import JSONEncoder as Jobs
+from pycs.database.util.JSONEncoder import JSONEncoder as DatabaseEncoder
+from pycs.jobs.util.JSONEncoder import JSONEncoder as JobsEncoder
 
 
 class JSONEncoder(Base):
     """
-    prepares job objects to be json encoded
+    prepares job and DB objects to be json encoded
     """
 
     def default(self, o: Any) -> Any:
         module = o.__class__.__module__
 
         if module.startswith('pycs.database'):
-            return Database().default(o)
+            return DatabaseEncoder().default(o)
+
         if module.startswith('pycs.jobs'):
-            return Jobs().default(o)
+            return JobsEncoder().default(o)
+
+        if isinstance(o, datetime.datetime):
+            return str(o)
+
+        if isinstance(o, dict):
+            return o
 
         return o.__dict__

+ 5 - 8
pycs/interfaces/MediaFile.py

@@ -89,15 +89,12 @@ class MediaFile:
             self.__notifications.add(self.__notifications.notifications.remove_result, result)
 
     def __get_results(self, origin: str) -> List[Union[MediaImageLabel, MediaBoundingBox]]:
-        def map_r(result: Result) -> Union[MediaImageLabel, MediaBoundingBox]:
-            if result.type == 'labeled-image':
-                return MediaImageLabel(result)
 
-            return MediaBoundingBox(result)
+        def result_to_media(result: Result) -> Union[MediaImageLabel, MediaBoundingBox]:
+            cls = MediaImageLabel if result.type == 'labeled-image' else MediaBoundingBox
+            return cls(result)
 
-        return list(map(map_r,
-                        filter(lambda r: r.origin == origin,
-                               self.__file.results())))
+        return [result_to_media(r) for r in self.__file.results.filter_by(origin=origin).all()]
 
     def results(self) -> List[Union[MediaImageLabel, MediaBoundingBox]]:
         """
@@ -127,7 +124,7 @@ class MediaFile:
             'frames': self.frames,
             'fps': self.fps,
             'path': self.path,
-            'filename': self.__file.name + self.__file.extension,
+            'filename': self.__file.filename,
             'results': list(map(lambda r: r.serialize(), self.results())),
             'predictions': list(map(lambda r: r.serialize(), self.predictions())),
         }

+ 3 - 3
pycs/interfaces/MediaFileList.py

@@ -49,11 +49,11 @@ class MediaFileList:
             source = self.__project
 
         if self.__label is None:
-            for file in source.files():
+            for file in source.files.all():
                 yield MediaFile(file, self.__notifications)
         else:
-            for file in source.files():
-                for result in file.results():
+            for file in source.files.all():
+                for result in file.results.all():
                     if result.label == self.__label:
                         yield MediaFile(file, self.__notifications)
                         break

+ 1 - 1
pycs/interfaces/MediaLabel.py

@@ -7,7 +7,7 @@ class MediaLabel:
     """
 
     def __init__(self, label: Label):
-        self.identifier = label.identifier
+        self.identifier = label.id
         self.parent = None
         self.children = []
         self.reference = label.reference

+ 9 - 12
pycs/interfaces/MediaStorage.py

@@ -1,6 +1,6 @@
 from typing import List
 
-from pycs.database.Database import Database
+from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationList import NotificationList
 from pycs.interfaces.MediaFileList import MediaFileList
 from pycs.interfaces.MediaLabel import MediaLabel
@@ -11,13 +11,13 @@ class MediaStorage:
     helper class for pipelines to interact with database entities
     """
 
-    def __init__(self, db: Database, project_id: int, notifications: NotificationList = None):
-        self.__db = db
+    def __init__(self, project_id: int, notifications: NotificationList = None):
         self.__project_id = project_id
         self.__notifications = notifications
 
-        self.__project = self.__db.project(self.__project_id)
-        self.__collections = self.__project.collections()
+        self.__project = Project.query.get(self.__project_id)
+        # this one is not used anywhere
+        # self.__collections = self.__project.collections.all()
 
     def labels(self) -> List[MediaLabel]:
         """
@@ -25,12 +25,12 @@ class MediaStorage:
 
         :return: list of labels
         """
-        label_list = self.__project.labels()
-        label_dict = {la.identifier: MediaLabel(la) for la in label_list}
+        label_list = self.__project.labels.all()
+        label_dict = {la.label: MediaLabel(la) for la in label_list}
         result = []
 
         for label in label_list:
-            medial_label = label_dict[label.identifier]
+            medial_label = label_dict[label.id]
 
             if label.parent_id is not None:
                 medial_label.parent = label_dict[label.parent_id]
@@ -46,10 +46,7 @@ class MediaStorage:
 
         :return: list of root-level labels (parent is None)
         """
-        return list(filter(
-            lambda ml: ml.parent is None,
-            self.labels()
-        ))
+        return [label for label in self.labels() if label.parent is None]
 
     def files(self) -> MediaFileList:
         """

+ 2 - 2
pycs/jobs/Job.py

@@ -11,8 +11,8 @@ class Job:
 
     # pylint: disable=too-few-public-methods
     def __init__(self, project: Project, job_type: str, name: str):
-        self.identifier = str(uuid1())
-        self.project_id = project.identifier
+        self.uuid = self.id = str(uuid1())
+        self.project_id = project.id
         self.type = job_type
         self.name = name
         self.exception = None

+ 1 - 1
pycs/jobs/JobRunner.py

@@ -94,7 +94,7 @@ class JobRunner:
         :return:
         """
         for i in range(len(self.__jobs)):
-            if self.__jobs[i].identifier == identifier:
+            if self.__jobs[i].id == identifier:
                 if self.__jobs[i].finished is not None:
                     job = self.__jobs[i]
                     del self.__jobs[i]

+ 2 - 0
settings.json

@@ -2,5 +2,7 @@
   "host": "",
   "port": 5000,
   "allowedOrigins": [],
+  "projects_folder": "projects",
   "database": "data2.sqlite3"
+
 }

+ 5 - 5
test/test_database.py

@@ -52,7 +52,7 @@ class TestDatabase(unittest.TestCase):
 
         # test insert
         for i in range(2):
-            self.assertEqual(models[i].identifier, i + 1)
+            self.assertEqual(models[i].id, i + 1)
             self.assertEqual(models[i].name, f'Model {i + 1}')
             self.assertEqual(models[i].description, f'Description for Model {i + 1}')
             self.assertEqual(models[i].root_folder, f'modeldir{i + 1}')
@@ -62,7 +62,7 @@ class TestDatabase(unittest.TestCase):
 
         # test copy
         copy, _ = models[0].copy_to('Copied Model', 'modeldir3')
-        self.assertEqual(copy.identifier, 3)
+        self.assertEqual(copy.id, 3)
         self.assertEqual(copy.name, 'Copied Model')
         self.assertEqual(copy.description, 'Description for Model 1')
         self.assertEqual(copy.root_folder, 'modeldir3')
@@ -75,7 +75,7 @@ class TestDatabase(unittest.TestCase):
         self.assertEqual(len(label_providers), 2)
 
         for i in range(2):
-            self.assertEqual(label_providers[i].identifier, i + 1)
+            self.assertEqual(label_providers[i].id, i + 1)
             self.assertEqual(label_providers[i].name, f'Label Provider {i + 1}')
             self.assertEqual(label_providers[i].description, f'Description for Label Provider {i + 1}')
             self.assertEqual(label_providers[i].root_folder, f'labeldir{i + 1}')
@@ -90,12 +90,12 @@ class TestDatabase(unittest.TestCase):
         for i in range(3):
             project = projects[i]
 
-            self.assertEqual(project.identifier, i + 1)
+            self.assertEqual(project.id, i + 1)
             self.assertEqual(project.name, f'Project {i + 1}')
             self.assertEqual(project.description, f'Project Description {i + 1}')
             self.assertEqual(project.model_id, i + 1)
             self.assertEqual(project.model().__dict__, models[i].__dict__)
-            self.assertEqual(project.label_provider_id, label_providers[i].identifier if i < 2 else None)
+            self.assertEqual(project.label_provider_id, label_providers[i].id if i < 2 else None)
             self.assertEqual(
                 project.label_provider().__dict__ if project.label_provider() is not None else None,
                 label_providers[i].__dict__ if i < 2 else None