From 72ce2fa39f002d3f56d57ed0ba65e48b2dc9e03a Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Wed, 3 Jan 2024 03:37:01 +0100 Subject: [PATCH 01/12] Multi-tenancy WIP --- backend/requirements.txt | 2 +- backend/schemas.json5 | 28 +++- backend/src/application/app_core.py | 16 ++- backend/src/application/stub_views.py | 6 +- backend/src/application/views.py | 10 +- backend/src/database/generated_models.py | 56 +++++--- backend/src/database/generated_schemas.py | 6 + backend/src/database/models.py | 19 +-- backend/src/database/tenant.py | 71 ++++++++++ .../versions/2319bc3735ac_add_company.py | 128 ++++++++++++++++++ backend/src/parser/json_schema.py | 17 ++- backend/src/parser/parser.py | 2 +- docker-compose.dev.yml | 2 +- 13 files changed, 324 insertions(+), 39 deletions(-) create mode 100644 backend/src/database/tenant.py create mode 100644 backend/src/migrations/versions/2319bc3735ac_add_company.py diff --git a/backend/requirements.txt b/backend/requirements.txt index c2ce429c..3f82f681 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ flask-sqlalchemy == 2.5.1 flask-cors == 3.0.10 flask-marshmallow == 0.14.0 marshmallow-sqlalchemy == 0.27.0 -sqlalchemy == 1.4.30 +sqlalchemy == 1.4.50 sqlalchemy-json == 0.5.0 psycopg2 == 2.9.3 requests == 2.27.1 diff --git a/backend/schemas.json5 b/backend/schemas.json5 index a9c66585..9fb98a26 100644 --- a/backend/schemas.json5 +++ b/backend/schemas.json5 @@ -348,7 +348,7 @@ "Main", "Links", "Skills", - // "Profiles", + // "Profiles", "Questions answers", ], "modal_size": "xl" @@ -1521,5 +1521,31 @@ }, }, ], + }, + { + "entity": "Company", + "comment": "Represents a company in the system", + "db": { + "tablename": "company", + "name": "Company" + }, + "fields": [ + { + "name": "id", + "type": "Integer", + "db": { + "primary_key": true, + "autoincrement": true + } + }, + { + "name": "name", + "type": "String", + "db": { + "nullable": false, + "max_length": 254 + } + } + ] } ] diff --git a/backend/src/application/app_core.py b/backend/src/application/app_core.py index a368bb58..01052728 100644 --- a/backend/src/application/app_core.py +++ b/backend/src/application/app_core.py @@ -42,6 +42,15 @@ def logger(self) -> logging.Logger: return lgr +def _store_tenant_id_in_context(): + g.tenant_id = None + tenant_id = request.headers.get("X-Tenant-ID") + if tenant_id: + g.tenant_id = tenant_id + else: + g.tenant_id = 2 + + def _store_authorized_user_in_context(): # Store authorized user in the context, if any g.user = None @@ -84,6 +93,7 @@ def invalid_api_usage(exc): @app.before_request def before_request(): + _store_tenant_id_in_context() _store_authorized_user_in_context() # enable CORS @@ -113,8 +123,10 @@ def before_request(): app.config["AIEYE_API_GENERATE_PROPOSAL_PIPELINE_IDS"].append( credentials["aieye_api_generate_proposal_digvijaj_pipeline_id"]) - app.config["AIEYE_API_HARENDRA_PIPELINE_ID"] = credentials["aieye_api_generate_proposal_harendra_pipeline_id"] - app.config["AIEYE_API_DIGVIJAJ_PIPELINE_ID"] = credentials["aieye_api_generate_proposal_digvijaj_pipeline_id"] + app.config["AIEYE_API_HARENDRA_PIPELINE_ID"] = credentials[ + "aieye_api_generate_proposal_harendra_pipeline_id"] + app.config["AIEYE_API_DIGVIJAJ_PIPELINE_ID"] = credentials[ + "aieye_api_generate_proposal_digvijaj_pipeline_id"] app.config["AIEYE_API_GENERATE_QUESTION_ANSWERS_PIPELINE_ID"] = credentials[ "aieye_api_generate_question_answers_pipeline_id"] diff --git a/backend/src/application/stub_views.py b/backend/src/application/stub_views.py index bf2ca53c..0d0f64cb 100644 --- a/backend/src/application/stub_views.py +++ b/backend/src/application/stub_views.py @@ -5,6 +5,8 @@ from flask import current_app, g, request from flask.views import MethodView + +from src.database.tenant import TenantScopedModel from src.application import config, exceptions, schemas from src.database.db import db @@ -173,7 +175,7 @@ def _ordinary_insert_or_update(self, ordinary_schema, ordinary_model): self.current_item = element return {"result": ordinary_schema().dump(element)} - def _update_existing_element(self, existing_element: db.Model, data: dict): + def _update_existing_element(self, existing_element: TenantScopedModel, data: dict): """Обновление элемента в базе. По умолчанию обновляются все элементы, значение которых не равно полученной в запросе схеме. Это поведение не всегда устраивает, иногда @@ -192,7 +194,7 @@ def _update_existing_element(self, existing_element: db.Model, data: dict): setattr(existing_element, key, value) db.session.commit() - def _update_only_from_request_fields(self, existing_element: db.Model, data: dict): + def _update_only_from_request_fields(self, existing_element: TenantScopedModel, data: dict): """Модифицируются - только те элементы, которые были в исходном запросе. Args: diff --git a/backend/src/application/views.py b/backend/src/application/views.py index f2f0a0b8..7b50fa76 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -17,6 +17,7 @@ from werkzeug.exceptions import BadRequest as FlaskBadRequest from werkzeug.exceptions import NotFound as FlaskNotFound +from src.database.tenant import TenantScopedModel from src.application import exceptions, schemas, utils from src.application.enums import GenerateProposalStyle from src.application.enums import ( @@ -393,6 +394,7 @@ class _UserWithCodeView(WrappedRESTView): # depending on implementing view. def _query_get(self, application_key): + # what the fuck is this. sub = ( db.session.query(distinct(application_key).label("code")) .filter(application_key.is_not(None)) @@ -821,7 +823,7 @@ def _unwrapped_post(self): return response - def _update_existing_element(self, existing_element: db.Model, data: dict): + def _update_existing_element(self, existing_element: TenantScopedModel, data: dict): self._update_only_from_request_fields(existing_element, data) @@ -866,7 +868,7 @@ def _unwrapped_get(self): if g.user.is_admin: # Админ видит всех пользователей системы. - query = User.query.all() + query = User.query.all_for_tenant() else: # Если не админ, то видит сам себя плюс тех пользователей, # которых ему показали. @@ -961,7 +963,7 @@ def dump_schema(self) -> type[marshmallow.Schema]: raise NotImplementedError("No schema provided") def _unwrapped_get(self): - return {"result": self.dump_schema(many=True).dump(self.model_cls.query.all())} + return {"result": self.dump_schema(many=True).dump(self.model_cls.query.all_for_tenant())} def _unwrapped_post(self): data = self._get_data_from_request(self.create_load_schema) @@ -1058,7 +1060,7 @@ class SkillsMatrix(MethodView): methods = ["POST", "GET"] def get(self, uuid): - skills = Skill.query.all() + skills = Skill.query.all_for_tenant() # Fetch the candidate using UUID candidate = Candidate.query.filter_by(skills_matrix_url=uuid).first() diff --git a/backend/src/database/generated_models.py b/backend/src/database/generated_models.py index fdfd6214..bfa5ccf9 100644 --- a/backend/src/database/generated_models.py +++ b/backend/src/database/generated_models.py @@ -8,14 +8,15 @@ import uuid from sqlalchemy.dialects.postgresql import ENUM as pgEnum from src.application.enums import SkillLevel - +from src.database.tenant import TenantScopedModel from src.database.db import db -class User(db.Model): +class User(TenantScopedModel): """ System users (who can log in from the WEB) """ __tablename__ = "users" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Идентификатор записи в таблице учета времени, primary key id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Username @@ -48,7 +49,7 @@ class User(db.Model): email_confirmation_expired = db.Column(db.DateTime(), default=None, nullable=True) -class UserVisibility(db.Model): +class UserVisibility(TenantScopedModel): """ Кто из пользователей системы кого может видеть. Пользователь - если он не админ - может видеть только тех, кто здесь указан. Остальных участников данный пользователь не видит. Это позволяет гибко настроить @@ -56,6 +57,7 @@ class UserVisibility(db.Model): Админы, разумеется, видят все. """ __tablename__ = "user_visibilities" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Идентификатор записи в таблице id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Кто может видеть (к кому относится эта запись) @@ -64,10 +66,11 @@ class UserVisibility(db.Model): visible_user_id = db.Column(db.Integer, db.ForeignKey(User.id, ondelete='CASCADE'), nullable=False) -class Freelancer(db.Model): +class Freelancer(TenantScopedModel): """ Зарегистрированные в системе фрилансеры """ __tablename__ = "freelancers" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Уникальный идентификатор записи в таблице фрилансеров id = db.Column(db.Integer, primary_key=True, autoincrement=True) # User-uploaded image, normalized to png of known size. @@ -104,10 +107,11 @@ class Freelancer(db.Model): questions_answers = db.relationship("FreelancersQuestionsAnswers", back_populates="freelancer", uselist=True, cascade="all, delete-orphan") -class Candidate(db.Model): +class Candidate(TenantScopedModel): """ Candidates (people who are not yet freelancers) """ __tablename__ = "candidates" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # id id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Candidate full name @@ -119,10 +123,11 @@ class Candidate(db.Model): url = db.Column(URLType, nullable=True, unique=True) -class Manager(db.Model): +class Manager(TenantScopedModel): """ Зарегистрированные в системе менеджеры """ __tablename__ = "managers" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Уникальный идентификатор записи в таблице id = db.Column(db.Integer, primary_key=True, autoincrement=True) # upwork код данного менеджера @@ -133,10 +138,11 @@ class Manager(db.Model): user = db.relationship("src.database.wrapped_models.User", back_populates="manager", uselist=False) -class Roles(db.Model): +class Roles(TenantScopedModel): """ Роли пользователей в системе """ __tablename__ = "roles" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Код данной роли, константа. role_code = db.Column(db.String(64), nullable=False) @@ -144,10 +150,11 @@ class Roles(db.Model): role_description = db.Column(db.String(254), nullable=True, default=None) -class UsersRoles(db.Model): +class UsersRoles(TenantScopedModel): """ Таблица отношений многие-ко-многим между пользователями и ролями """ __tablename__ = "users_roles" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Идентификатор пользователя, которому назначена данная роль user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete='CASCADE'), nullable=False) @@ -155,10 +162,11 @@ class UsersRoles(db.Model): role_id = db.Column(db.Integer, db.ForeignKey("roles.id", ondelete='CASCADE'), nullable=False) -class Questions(db.Model): +class Questions(TenantScopedModel): """ Possible freelancer question. """ __tablename__ = "questions" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Question detailed description description = db.Column(db.Text) @@ -167,10 +175,11 @@ class Questions(db.Model): questions_answers = db.relationship("src.database.generated_models.FreelancersQuestionsAnswers", back_populates="question", uselist=True, passive_deletes=True) -class FreelancersQuestionsAnswers(db.Model): +class FreelancersQuestionsAnswers(TenantScopedModel): """ Relation between Freelancer Question answers and questions. """ __tablename__ = "freelancers_questions_answers" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) freelancer_id = db.Column(db.Integer, db.ForeignKey(Freelancer.id, ondelete='CASCADE'), nullable=False) freelancer = db.relationship("src.database.wrapped_models.Freelancer", back_populates="questions_answers", uselist=False, passive_deletes=True) @@ -180,10 +189,11 @@ class FreelancersQuestionsAnswers(db.Model): question = db.relationship("src.database.generated_models.Questions", back_populates="questions_answers", uselist=False, passive_deletes=True) -class SkillCategory(db.Model): +class SkillCategory(TenantScopedModel): """ Freelancer skill category: groups similar skills together. """ __tablename__ = "skill_categories" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Category name (short, descriptive title) name = db.Column(db.String(127), nullable=False) @@ -195,10 +205,11 @@ class SkillCategory(db.Model): questions = db.relationship("Questions", secondary="question_2_skill_category", back_populates="categories", uselist=True, passive_deletes=True) -class Skill(db.Model): +class Skill(TenantScopedModel): """ Possible freelancer skill. """ __tablename__ = "skills" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Corresponding Freelancer-Skill matches freelancer_skills = db.relationship("FreelancerSkill", back_populates="skill", uselist=True, cascade="all, delete-orphan") @@ -212,10 +223,11 @@ class Skill(db.Model): categories = db.relationship("SkillCategory", secondary="skill_2_skill_category", back_populates="skills", uselist=True, passive_deletes=True) -class CandidateSkill(db.Model): +class CandidateSkill(TenantScopedModel): """ Candidate skills (many-to-many Candidate to Skill matching) """ __tablename__ = "candidate_skills" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) candidate_id = db.Column(db.Integer, db.ForeignKey(Candidate.id, ondelete='CASCADE'), nullable=False) candidate = db.relationship("Candidate", back_populates="skills", uselist=False, passive_deletes=True) @@ -227,10 +239,11 @@ class CandidateSkill(db.Model): experience = db.Column(db.Text) -class FreelancerSkill(db.Model): +class FreelancerSkill(TenantScopedModel): """ Freelancer skills (many-to-many Freelancer to Skill matching) """ __tablename__ = "freelancer_skills" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # If NULL, the skill belongs to all team members freelancer_id = db.Column(db.Integer, db.ForeignKey(Freelancer.id, ondelete='CASCADE'), nullable=True) @@ -244,19 +257,30 @@ class FreelancerSkill(db.Model): experience = db.Column(db.Text) -class Skill2SkillCategory(db.Model): +class Skill2SkillCategory(TenantScopedModel): """ Skill to skill category (many-to-many intermediate table) """ __tablename__ = "skill_2_skill_category" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) skill_category_id = db.Column(db.Integer, db.ForeignKey(SkillCategory.id, ondelete='CASCADE'), nullable=False) skill_id = db.Column(db.Integer, db.ForeignKey(Skill.id, ondelete='CASCADE'), nullable=False) -class Question2SkillCategory(db.Model): +class Question2SkillCategory(TenantScopedModel): """ Question to skill category (many-to-many intermediate table) """ __tablename__ = "question_2_skill_category" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) skill_category_id = db.Column(db.Integer, db.ForeignKey(SkillCategory.id, ondelete='CASCADE'), nullable=False) question_id = db.Column(db.Integer, db.ForeignKey(Questions.id, ondelete='CASCADE'), nullable=False) + + +class Company(TenantScopedModel): + """ Represents a company in the system + """ + __tablename__ = "company" + company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(254), nullable=False) diff --git a/backend/src/database/generated_schemas.py b/backend/src/database/generated_schemas.py index a69285c8..ce19879e 100644 --- a/backend/src/database/generated_schemas.py +++ b/backend/src/database/generated_schemas.py @@ -96,3 +96,9 @@ class Question2SkillCategory(ma.SQLAlchemyAutoSchema): class Meta: model = generated_models.Question2SkillCategory include_fk = True + + +class Company(ma.SQLAlchemyAutoSchema): + class Meta: + model = generated_models.Company + include_fk = True diff --git a/backend/src/database/models.py b/backend/src/database/models.py index dc7ce845..dffe2663 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -6,6 +6,7 @@ from alembic_utils.pg_view import PGView from sqlalchemy_utils import UUIDType +from src.database.tenant import TenantScopedModel from src.application.enums import ProposalStatus from src.database.db import db from src.database.wrapped_models import User @@ -65,12 +66,12 @@ } -class Application(db.Model): +class Application(TenantScopedModel): locals().update({k: v() for k, v in _APPLICATION_FIELDS.items()}) __tablename__ = "applications" -class Proposal(db.Model): +class Proposal(TenantScopedModel): locals().update({k: v() for k, v in _PROPOSAL_FIELDS.items()}) proposal_questions_answers = db.relationship("ProposalQuestionAnswer", back_populates="proposal") @@ -86,7 +87,7 @@ def get_current_status(self): return latest_status_obj.status if latest_status_obj else None -class Proposal2StatusRel(db.Model): +class Proposal2StatusRel(TenantScopedModel): id = db.Column(db.Integer, primary_key=True, autoincrement=True) proposal_id = db.Column(db.Integer, db.ForeignKey(Proposal.id, ondelete="CASCADE")) timestamp = db.Column(db.DateTime, nullable=False, server_default=db.func.current_timestamp()) @@ -94,7 +95,7 @@ class Proposal2StatusRel(db.Model): __tablename__ = "proposal2_status_rel" -class ProposalQuestionAnswer(db.Model): +class ProposalQuestionAnswer(TenantScopedModel): id = db.Column(db.Integer, primary_key=True, autoincrement=True) question_text = db.Column(db.String, nullable=False) answer_text = db.Column(db.String, nullable=True) @@ -104,7 +105,7 @@ class ProposalQuestionAnswer(db.Model): __tablename__ = "proposal_questions_answers" -class Client(db.Model): +class Client(TenantScopedModel): """The customer from upwork site""" __tablename__ = "clients" @@ -122,7 +123,7 @@ class Client(db.Model): updated_at = db.Column(db.DateTime) -class Job(db.Model): +class Job(TenantScopedModel): __tablename__ = "jobs" id = db.Column(db.String(32), primary_key=True) client_id = db.Column(db.String(32), index=True) @@ -143,7 +144,7 @@ class Job(db.Model): fixed_price = db.Column(db.Text) -class ReadByUser(db.Model): +class ReadByUser(TenantScopedModel): __tablename__ = "read_by_user" __table_args__ = ( db.Index( @@ -157,7 +158,7 @@ class ReadByUser(db.Model): entity_table = db.Column(db.String(32), index=True) -class RegistrationRequest(db.Model): +class RegistrationRequest(TenantScopedModel): __tablename__ = "registration_request" id = db.Column(db.Integer, primary_key=True, autoincrement=True) email = db.Column(db.String(64), nullable=False) @@ -168,7 +169,7 @@ def __str__(self): return f"{self.email} - {self.uuid}" -class PGViewModel(db.Model): +class PGViewModel(TenantScopedModel): __table_args__ = {"info": {"is_view": True}} __view_kind__ = PGView # May be PGMaterializedView too __abstract__ = True diff --git a/backend/src/database/tenant.py b/backend/src/database/tenant.py new file mode 100644 index 00000000..bda0351c --- /dev/null +++ b/backend/src/database/tenant.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging + +from flask import g +from flask_sqlalchemy import BaseQuery + +from src.database.db import db + + +class TenantScopedQuery(BaseQuery): + + def get(self, id): + # Automatically add the company_id filter when retrieving a single record by ID + return super(TenantScopedQuery, self).filter_by(id=id, company_id=g.tenant_id).first() + + def filter(self, *criteria): + # Call the original filter method with the provided criteria + query = super(TenantScopedQuery, self).filter(*criteria) + + logging.error(f"Query: {query}") + + logging.error(f"Tenant id: {g.tenant_id}") + + # Manually construct the tenant filter condition + tenant_condition = (self._entity_zero().class_.company_id == g.tenant_id,) + + logging.error(f"Tenant condition: {tenant_condition}") + + # Add the tenant condition to the query's criterion list + query._where_criteria = query._where_criteria + tenant_condition + + logging.error(f"Query with tenant condition: {query}") + + return query + + def filter_by(self, **kwargs): + logging.error(f"Filter by kwargs: {kwargs}") + super(TenantScopedQuery, self).filter_by(**kwargs) + + def all(self): + raise ValueError("Cannot use all() directly. Use all_for_tenant() instead.") + + def all_for_tenant(self): + logging.error(f"All for tenant id: {g.tenant_id}") + # Automatically add the company_id filter when retrieving all records + return super(TenantScopedQuery, self).filter_by(company_id=g.tenant_id)._iter().all() + + def _entity_zero(self): + # This method should return the primary entity mapper + return self._only_full_mapper_zero("filter") + + +class TenantScopedModel(db.Model): + __abstract__ = True + query_class = TenantScopedQuery + + # Assuming every table has a 'company_id' field + company_id = db.Column(db.Integer, nullable=False, index=True) + + # Override the save method to automatically set company_id + def save(self, *args, **kwargs): + # Always set company_id to the current tenant's ID, regardless of user input + logging.info(f"Tenant id: {g.tenant_id}") + self.company_id = g.tenant_id + super().save(*args, **kwargs) + + def update(self, **kwargs): + if 'company_id' in kwargs: + raise ValueError("Cannot modify company_id.") + super(TenantScopedModel, self).update(**kwargs) diff --git a/backend/src/migrations/versions/2319bc3735ac_add_company.py b/backend/src/migrations/versions/2319bc3735ac_add_company.py new file mode 100644 index 00000000..daa597f4 --- /dev/null +++ b/backend/src/migrations/versions/2319bc3735ac_add_company.py @@ -0,0 +1,128 @@ +"""add company + +Revision ID: 2319bc3735ac +Revises: 55585d78287e +Create Date: 2024-01-02 01:25:02.670048 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '2319bc3735ac' +down_revision = '55585d78287e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('company', + sa.Column('company_id', sa.Integer(), server_default='1', nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=254), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['company.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.execute("INSERT INTO company (id, name) VALUES (1, '1gency')") + op.add_column('applications', sa.Column('company_id', sa.Integer(), nullable=False)) + op.create_index(op.f('ix_applications_company_id'), 'applications', ['company_id'], unique=False) + op.add_column('candidate_skills', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'candidate_skills', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('candidates', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'candidates', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('clients', sa.Column('company_id', sa.Integer(), nullable=False)) + op.create_index(op.f('ix_clients_company_id'), 'clients', ['company_id'], unique=False) + op.add_column('freelancer_skills', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'freelancer_skills', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('freelancers', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'freelancers', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('freelancers_questions_answers', + sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'freelancers_questions_answers', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('jobs', sa.Column('company_id', sa.Integer(), nullable=False, server_default='1')) + op.create_index(op.f('ix_jobs_company_id'), 'jobs', ['company_id'], unique=False) + op.add_column('managers', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'managers', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('proposal2_status_rel', sa.Column('company_id', sa.Integer(), nullable=False, server_default='1')) + op.create_index(op.f('ix_proposal2_status_rel_company_id'), 'proposal2_status_rel', ['company_id'], unique=False) + op.add_column('proposal_questions_answers', sa.Column('company_id', sa.Integer(), nullable=False, server_default='1')) + op.create_index(op.f('ix_proposal_questions_answers_company_id'), 'proposal_questions_answers', ['company_id'], + unique=False) + op.add_column('proposals2', sa.Column('company_id', sa.Integer(), nullable=False, server_default='1')) + op.create_index(op.f('ix_proposals2_company_id'), 'proposals2', ['company_id'], unique=False) + op.add_column('question_2_skill_category', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'question_2_skill_category', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('questions', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'questions', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('read_by_user', sa.Column('company_id', sa.Integer(), nullable=False, server_default='1')) + op.create_index(op.f('ix_read_by_user_company_id'), 'read_by_user', ['company_id'], unique=False) + op.add_column('registration_request', sa.Column('company_id', sa.Integer(), nullable=False, server_default='1')) + op.create_index(op.f('ix_registration_request_company_id'), 'registration_request', ['company_id'], unique=False) + op.add_column('roles', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'roles', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('skill_2_skill_category', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'skill_2_skill_category', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('skill_categories', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'skill_categories', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('skills', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'skills', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('user_visibilities', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'user_visibilities', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('users', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'users', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.add_column('users_roles', sa.Column('company_id', sa.Integer(), server_default='1', nullable=False)) + op.create_foreign_key(None, 'users_roles', 'company', ['company_id'], ['id'], ondelete='SET NULL') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users_roles', type_='foreignkey') + op.drop_column('users_roles', 'company_id') + op.drop_constraint(None, 'users', type_='foreignkey') + op.drop_column('users', 'company_id') + op.drop_constraint(None, 'user_visibilities', type_='foreignkey') + op.drop_column('user_visibilities', 'company_id') + op.drop_constraint(None, 'skills', type_='foreignkey') + op.drop_column('skills', 'company_id') + op.drop_constraint(None, 'skill_categories', type_='foreignkey') + op.drop_column('skill_categories', 'company_id') + op.drop_constraint(None, 'skill_2_skill_category', type_='foreignkey') + op.drop_column('skill_2_skill_category', 'company_id') + op.drop_constraint(None, 'roles', type_='foreignkey') + op.drop_column('roles', 'company_id') + op.drop_index(op.f('ix_registration_request_company_id'), table_name='registration_request') + op.drop_column('registration_request', 'company_id') + op.drop_index(op.f('ix_read_by_user_company_id'), table_name='read_by_user') + op.drop_column('read_by_user', 'company_id') + op.drop_constraint(None, 'questions', type_='foreignkey') + op.drop_column('questions', 'company_id') + op.drop_constraint(None, 'question_2_skill_category', type_='foreignkey') + op.drop_column('question_2_skill_category', 'company_id') + op.drop_index(op.f('ix_proposals2_company_id'), table_name='proposals2') + op.drop_column('proposals2', 'company_id') + op.drop_index(op.f('ix_proposal_questions_answers_company_id'), table_name='proposal_questions_answers') + op.drop_column('proposal_questions_answers', 'company_id') + op.drop_index(op.f('ix_proposal2_status_rel_company_id'), table_name='proposal2_status_rel') + op.drop_column('proposal2_status_rel', 'company_id') + op.drop_constraint(None, 'managers', type_='foreignkey') + op.drop_column('managers', 'company_id') + op.drop_index(op.f('ix_jobs_company_id'), table_name='jobs') + op.drop_column('jobs', 'company_id') + op.drop_constraint(None, 'freelancers_questions_answers', type_='foreignkey') + op.drop_column('freelancers_questions_answers', 'company_id') + op.drop_constraint(None, 'freelancers', type_='foreignkey') + op.drop_column('freelancers', 'company_id') + op.drop_constraint(None, 'freelancer_skills', type_='foreignkey') + op.drop_column('freelancer_skills', 'company_id') + op.drop_index(op.f('ix_clients_company_id'), table_name='clients') + op.drop_column('clients', 'company_id') + op.drop_constraint(None, 'candidates', type_='foreignkey') + op.drop_column('candidates', 'company_id') + op.drop_constraint(None, 'candidate_skills', type_='foreignkey') + op.drop_column('candidate_skills', 'company_id') + op.drop_index(op.f('ix_applications_company_id'), table_name='applications') + op.drop_column('applications', 'company_id') + op.drop_table('company') + # ### end Alembic commands ### diff --git a/backend/src/parser/json_schema.py b/backend/src/parser/json_schema.py index 0313fdde..4e9b4484 100644 --- a/backend/src/parser/json_schema.py +++ b/backend/src/parser/json_schema.py @@ -352,7 +352,20 @@ def parse( if not isinstance(source, list) and raise_exception: raise ValueError(f"{self.parent.name}, fields is not list") - self.parent.fields = [] + company_field = ForeignKeyField() + company_field.parent = self + company_field.parse({ + "name": "company_id", + "type": "ForeignKey", + "db": { + "field_type": "Integer", + "reference": "'company.id'", + "ondelete": "SET NULL", + "server_default": "1", + "nullable": False + }, + }, raise_exception=False) + self.parent.fields = [company_field] for one_field_dict in source: field_type = one_field_dict.get("type") @@ -464,7 +477,7 @@ def write_model(self, file: formatter.File): tablename = self.db.tablename if self.db and self.db.tablename else None comment = self.db.comment if self.db and self.db.comment else self.comment - file.write(f"class {name}(db.Model):") + file.write(f"class {name}(TenantScopedModel):") file.level_up() self._write_comment_and_docstring(file, comment) diff --git a/backend/src/parser/parser.py b/backend/src/parser/parser.py index 3caa0652..3d5e869d 100644 --- a/backend/src/parser/parser.py +++ b/backend/src/parser/parser.py @@ -102,7 +102,7 @@ def _parse_entities_dict(self): def _write_db_header(self, file: formatter.File): self._write_additionals_db_imports(file) - file.writeln() + file.write("from src.database.tenant import TenantScopedModel") file.write("from src.database.db import db") def _write_ma_header(self, file: formatter.File): diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 085e64cb..f21b204a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -43,7 +43,7 @@ services: - '5434:5434' volumes: - db_volume:/var/lib/postgresql/data - command: '-p 5434' + command: [ "postgres", "-c", "log_statement=all", "-p", "5434"] volumes: db_volume: From dc75b4b4578ee22532f394ac28a4f9c245cdc1eb Mon Sep 17 00:00:00 2001 From: "Sergey A." Date: Wed, 3 Jan 2024 21:13:57 +0500 Subject: [PATCH 02/12] add `return` --- backend/src/database/tenant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/database/tenant.py b/backend/src/database/tenant.py index bda0351c..aa338e06 100644 --- a/backend/src/database/tenant.py +++ b/backend/src/database/tenant.py @@ -36,7 +36,7 @@ def filter(self, *criteria): def filter_by(self, **kwargs): logging.error(f"Filter by kwargs: {kwargs}") - super(TenantScopedQuery, self).filter_by(**kwargs) + return super(TenantScopedQuery, self).filter_by(**kwargs) def all(self): raise ValueError("Cannot use all() directly. Use all_for_tenant() instead.") From 26c579ed4c78dd890c46d6731e5d2785ebf5852c Mon Sep 17 00:00:00 2001 From: "Sergey A." Date: Wed, 3 Jan 2024 21:26:32 +0500 Subject: [PATCH 03/12] add filter by g.user.company_id in _query_get --- backend/src/application/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/application/views.py b/backend/src/application/views.py index 7b50fa76..b06d9f82 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -398,7 +398,6 @@ def _query_get(self, application_key): sub = ( db.session.query(distinct(application_key).label("code")) .filter(application_key.is_not(None)) - .union(db.session.query(self.model_cls.code)) .subquery("sub") ) @@ -407,6 +406,7 @@ def _query_get(self, application_key): .outerjoin(self.model_cls, self.model_cls.code == sub.c.code) .order_by(func.coalesce(self.model_cls.name, sub.c.code)) .options(joinedload(self.model_cls.user)) + .filter(self.model_cls.company_id == g.user.company_id) .all() ) From f34cd74f00610ec29f418c570a1a57bcea453f09 Mon Sep 17 00:00:00 2001 From: "Sergey A." Date: Wed, 3 Jan 2024 21:48:15 +0500 Subject: [PATCH 04/12] `_query_get` method's rewrite --- backend/src/application/views.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/backend/src/application/views.py b/backend/src/application/views.py index b06d9f82..36bdf986 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -393,26 +393,16 @@ class _UserWithCodeView(WrappedRESTView): # In context of this view, `guy` refers to freelancer or manager # depending on implementing view. - def _query_get(self, application_key): - # what the fuck is this. - sub = ( - db.session.query(distinct(application_key).label("code")) - .filter(application_key.is_not(None)) - .subquery("sub") - ) - + def _query_get(self): db_data = ( - db.session.query(sub.c.code, self.model_cls) - .outerjoin(self.model_cls, self.model_cls.code == sub.c.code) - .order_by(func.coalesce(self.model_cls.name, sub.c.code)) + db.session.query(self.model_cls) + .order_by(func.coalesce(self.model_cls.name, self.model_cls.code)) .options(joinedload(self.model_cls.user)) .filter(self.model_cls.company_id == g.user.company_id) .all() ) - return self.dump_schema_cls(many=True).dump( - row[1] for row in db_data if row[1] is not None - ) + [{"code": row[0]} for row in db_data if row[1] is None] + return self.dump_schema_cls(many=True).dump(db_data) def _unwrapped_post(self): data = self._get_data_from_request(without_id(self.request_schema_cls)) @@ -464,7 +454,7 @@ def _get_data_from_request(self, schema): return data def _unwrapped_get(self): - all_freelancers = self._query_get(models.Application.freelancerRid) + all_freelancers = self._query_get() if not g.user.is_admin: # Это не администратор, он может видеть только сам себя. all_freelancers = [ @@ -481,7 +471,7 @@ class Managers(_UserWithCodeView): reverse_attr_name = "manager_id" def _unwrapped_get(self): - return {"result": self._query_get(Application.createdByUserUID)} + return {"result": self._query_get()} # -------------------------------------------------------------------------------------- From e6ad0b38e6e150685e614163d03f76f7786ec9e8 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Tue, 9 Jan 2024 22:38:35 +0100 Subject: [PATCH 05/12] Apply tenant filters where appropriate: replace all() with all_for_tenant() when no filters in query & add filter_by to db.session.query calls --- backend/src/application/views.py | 27 ++++++++++++++++-------- backend/src/database/tenant.py | 2 +- backend/src/handlers.py | 36 -------------------------------- 3 files changed, 19 insertions(+), 46 deletions(-) delete mode 100644 backend/src/handlers.py diff --git a/backend/src/application/views.py b/backend/src/application/views.py index 36bdf986..47a30d2f 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -17,7 +17,6 @@ from werkzeug.exceptions import BadRequest as FlaskBadRequest from werkzeug.exceptions import NotFound as FlaskNotFound -from src.database.tenant import TenantScopedModel from src.application import exceptions, schemas, utils from src.application.enums import GenerateProposalStyle from src.application.enums import ( @@ -37,6 +36,7 @@ FreelancersQuestionsAnswers, Candidate, CandidateSkill from src.database.generated_models import Skill, SkillCategory from src.database.models import Application, Proposal, Proposal2StatusRel, ProposalQuestionAnswer, RegistrationRequest +from src.database.tenant import TenantScopedModel from src.database.wrapped_models import Freelancer, User from src.database.wrapped_schemas import without_id from src.utils import generate_password @@ -398,7 +398,7 @@ def _query_get(self): db.session.query(self.model_cls) .order_by(func.coalesce(self.model_cls.name, self.model_cls.code)) .options(joinedload(self.model_cls.user)) - .filter(self.model_cls.company_id == g.user.company_id) + .filter(self.model_cls.company_id == g.tenant_id) .all() ) @@ -493,6 +493,7 @@ def get(self, freelancer_id: int): id_and_photo = ( db.session.query(Freelancer.id, Freelancer.photo) .filter_by(id=freelancer_id) + .filter_by(company_id=g.tenant_id) .first() ) if not id_and_photo: @@ -562,6 +563,7 @@ def fetch_proposal_details(proposal_id): ) .outerjoin(Freelancer, Freelancer.code == Proposal.freelancerRid) .outerjoin(Manager, Manager.code == Proposal.createdByUserUID) + .filter_by(company_id=g.tenant_id) .filter(Proposal.id == proposal_id) ) @@ -570,6 +572,7 @@ def fetch_proposal_details(proposal_id): latest_status_query = ( db.session.query(Proposal2StatusRel) .filter(Proposal2StatusRel.proposal_id == proposal_id) + .filter_by(company_id=g.tenant_id) .order_by(desc(Proposal2StatusRel.timestamp)) .limit(1) ) @@ -579,6 +582,7 @@ def fetch_proposal_details(proposal_id): questions_answers_query = ( db.session.query(ProposalQuestionAnswer) .filter(ProposalQuestionAnswer.proposal_id == proposal_id) + .filter_by(company_id=g.tenant_id) .order_by(asc(ProposalQuestionAnswer.id)) ) @@ -615,12 +619,16 @@ def fetch_all_proposals(): Proposal.id == proposal_status_rel_alias.proposal_id, proposal_status_rel_alias.timestamp == db.session.query( func.max(Proposal2StatusRel.timestamp) - ).filter( + ) + .filter( Proposal2StatusRel.proposal_id == Proposal.id - ).correlate(Proposal) + ) + .filter_by(company_id=g.tenant_id) + .correlate(Proposal) .scalar_subquery() ) ) + .filter_by(company_id=g.tenant_id) .outerjoin(Freelancer, Freelancer.code == Proposal.freelancerRid) .outerjoin(Manager, Manager.code == Proposal.createdByUserUID) .order_by(proposal_status_rel_alias.timestamp.desc()) @@ -791,7 +799,7 @@ def _prepare_new_data(self, app_data) -> PrepareAppDataResult: class Jobs(WrappedRESTView): def _unwrapped_get(self): - query = models.Job.query.order_by(models.Job.updated_at.desc()).all() + query = models.Job.query.order_by(models.Job.updated_at.desc()).all_for_tenant() return { "result": { "jobs": schemas.JobSchema(many=True).dump(query) @@ -822,7 +830,7 @@ def _update_existing_element(self, existing_element: TenantScopedModel, data: di class Clients(WrappedRESTView): def _unwrapped_get(self): - query = models.Client.query.order_by(models.Client.updated_at.desc()).all() + query = models.Client.query.order_by(models.Client.updated_at.desc()).all_for_tenant() return {"result": schemas.ClientSchema(many=True).dump(query)} def _unwrapped_post(self): @@ -841,6 +849,7 @@ def _unwrapped_get(self): distinct(func.cast(Application.terms, Integer)).label("rate") ) .filter(Application.terms.is_not(None)) + .filter_by(company_id=g.tenant_id) .order_by(text("rate")) ) @@ -862,9 +871,9 @@ def _unwrapped_get(self): else: # Если не админ, то видит сам себя плюс тех пользователей, # которых ему показали. - visible_users = db.session.query( - generated_models.UserVisibility.visible_user_id - ).filter(generated_models.UserVisibility.observer_id == g.user.id) + visible_users = (db.session.query(generated_models.UserVisibility.visible_user_id) + .filter(generated_models.UserVisibility.observer_id == g.user.id) + .filter_by(company_id=g.tenant_id)) query = User.query.filter( or_( diff --git a/backend/src/database/tenant.py b/backend/src/database/tenant.py index aa338e06..77b7892b 100644 --- a/backend/src/database/tenant.py +++ b/backend/src/database/tenant.py @@ -61,7 +61,7 @@ class TenantScopedModel(db.Model): # Override the save method to automatically set company_id def save(self, *args, **kwargs): # Always set company_id to the current tenant's ID, regardless of user input - logging.info(f"Tenant id: {g.tenant_id}") + logging.info(f"Saving model: {self} with company_id: {g.tenant_id}") self.company_id = g.tenant_id super().save(*args, **kwargs) diff --git a/backend/src/handlers.py b/backend/src/handlers.py deleted file mode 100644 index cf3cd272..00000000 --- a/backend/src/handlers.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Callable, Dict, Iterable, Optional, Union - -from marshmallow import Schema, ValidationError -from sqlalchemy.orm import query - -Handler = Callable[[Dict], Union[Iterable[Dict], Dict]] -Validator = Callable[[Dict, Optional[Schema]], Dict] - - -def default_validator(params: Dict, request_schema: Optional[Schema]) -> Dict: - if request_schema is None: - return params - - return request_schema.load(params) - - -def make_default_handler( - query_def: Union[Callable[[Dict], query.Query], query.Query], - result_schema: Schema, - request_schema: Optional[Schema] = None, - validator: Validator = default_validator, -) -> Handler: - def handler(params: Dict) -> Iterable[Dict]: - try: - query_params = validator(params, request_schema) - except ValidationError as exc: - raise ValidationError("Invalid parameters: %s" % exc.messages) from exc - - if isinstance(query_def, query.Query): - rows = query_def.params(**query_params).all() - else: - rows = query_def(query_params).params(**query_params).all() - - return result_schema.dump(rows) - - return handler From 926c2b96750e822640656eb0820dd1efe851f5b6 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Wed, 17 Jan 2024 00:54:10 +0100 Subject: [PATCH 06/12] Fetch proposal ids from aieye --- backend/aieye_credentials.json.example | 2 -- backend/src/application/app_core.py | 14 +++---------- backend/src/application/enums.py | 6 ------ backend/src/application/schemas.py | 3 +-- backend/src/application/utils.py | 25 ++++++++++++++++++++++++ backend/src/application/views.py | 27 ++++---------------------- backend/src/database/dbconfig.py | 2 +- docker-compose.dev.yml | 1 + 8 files changed, 35 insertions(+), 45 deletions(-) diff --git a/backend/aieye_credentials.json.example b/backend/aieye_credentials.json.example index ec277bd0..8bce5c3f 100644 --- a/backend/aieye_credentials.json.example +++ b/backend/aieye_credentials.json.example @@ -1,7 +1,5 @@ { "aieye_api_public_token": "", "aieye_api_host": "http://127.0.0.1:8000", - "aieye_api_generate_proposal_harendra_pipeline_id": 63, - "aieye_api_generate_proposal_digvijaj_pipeline_id": 64, "aieye_api_generate_question_answers_pipeline_id": 6 } diff --git a/backend/src/application/app_core.py b/backend/src/application/app_core.py index 63686284..d1b2c451 100644 --- a/backend/src/application/app_core.py +++ b/backend/src/application/app_core.py @@ -7,12 +7,12 @@ from flask import Flask, current_app, g, jsonify, request from flask_cors import CORS -# Добавление автоматически сгенерированных моделей. +logger = logging.getLogger() + try: # pylint: disable-next=wildcard-import,unused-wildcard-import from src.database.generated_models import * except (ImportError, ModuleNotFoundError): - logger = logging.getLogger() logger.error("generated_models module not found") from src.database import dbconfig @@ -103,22 +103,14 @@ def before_request(): try: app.config["AIEYE_API_PUBLIC_TOKEN"] = credentials["aieye_api_public_token"] app.config["AIEYE_API_HOST"] = credentials["aieye_api_host"] - app.config["AIEYE_API_GENERATE_PROPOSAL_PIPELINE_IDS"] = [] - app.config["AIEYE_API_GENERATE_PROPOSAL_PIPELINE_IDS"].append( - credentials["aieye_api_generate_proposal_harendra_pipeline_id"]) - app.config["AIEYE_API_GENERATE_PROPOSAL_PIPELINE_IDS"].append( - credentials["aieye_api_generate_proposal_digvijaj_pipeline_id"]) - - app.config["AIEYE_API_HARENDRA_PIPELINE_ID"] = credentials["aieye_api_generate_proposal_harendra_pipeline_id"] - app.config["AIEYE_API_DIGVIJAJ_PIPELINE_ID"] = credentials["aieye_api_generate_proposal_digvijaj_pipeline_id"] app.config["AIEYE_API_GENERATE_QUESTION_ANSWERS_PIPELINE_ID"] = credentials[ "aieye_api_generate_question_answers_pipeline_id"] + logger.info(f"AIEYE API Host: {app.config['AIEYE_API_HOST']}") except KeyError: app.logger.warning("AiEye credentials file invalid.") app.config["AIEYE_API_PUBLIC_TOKEN"] = None app.config["AIEYE_API_HOST"] = None - app.config["AIEYE_API_GENERATE_PROPOSAL_PIPELINE_IDS"] = [] app.config["AIEYE_API_GENERATE_QUESTION_ANSWERS_PIPELINE_ID"] = None except FileNotFoundError: app.logger.warning("AiEye credentials file not found.") diff --git a/backend/src/application/enums.py b/backend/src/application/enums.py index 09950217..7a6180e9 100644 --- a/backend/src/application/enums.py +++ b/backend/src/application/enums.py @@ -56,12 +56,6 @@ class SkillLevel(int, Enum): EXPERT = 5 -@unique -class GenerateProposalStyle(UnderpinningEnum): - FORMAL_STYLE = 1 - INFORMAL_STYLE = 2 - - APPLICATION_UID_STR = "applicationUID" ONHOLD_STR = "onHold" STATUS_STR = "status" diff --git a/backend/src/application/schemas.py b/backend/src/application/schemas.py index 770d6cdd..498773d4 100644 --- a/backend/src/application/schemas.py +++ b/backend/src/application/schemas.py @@ -7,7 +7,7 @@ validate, validates_schema, ) -from src.application.enums import GenerateProposalStyle, ProposalStatus +from src.application.enums import ProposalStatus from src.application.marshmallow_ma import ma from src.database import models from werkzeug.exceptions import BadRequest @@ -105,7 +105,6 @@ class UserUpdateRequest(UserCommonRequest): class GenerateProposalRequestSchema(Schema): template_id = fields.Integer(allow_none=False, required=True) - style_id = fields.Integer(required=True, validate=validate.OneOf(GenerateProposalStyle.values())) job_description = fields.String(allow_none=False, required=True) job_title = fields.String(allow_none=False, required=True) tags = fields.String(allow_none=True, required=False) diff --git a/backend/src/application/utils.py b/backend/src/application/utils.py index c742e1b2..38c55717 100644 --- a/backend/src/application/utils.py +++ b/backend/src/application/utils.py @@ -1,7 +1,9 @@ +import logging from datetime import datetime, timezone import requests from flask import current_app + from src.application import exceptions @@ -9,6 +11,29 @@ def now_as_milliseconds_timestamp() -> int: return int(datetime.utcnow().replace(tzinfo=timezone.utc).timestamp() * 1000) +def fetch_aieye_pipelines(tag=None): + try: + response = requests.get( + url=f'{current_app.config["AIEYE_API_HOST"]}/api/pipelines/' + (f'?tag={tag}' if tag else ''), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {current_app.config['AIEYE_API_PUBLIC_TOKEN']}" + }, + ) + response.raise_for_status() + except requests.RequestException as exc: + raise exceptions.BadRequest(str(exc)) + else: + pipelines_data = response.json() + try: + logging.info(f"Successfully fetched pipelines data: {pipelines_data}") + pipeline_ids = {pipeline['name']: pipeline['id'] for pipeline in pipelines_data} + + return pipeline_ids + except KeyError as exc: + raise exceptions.BadRequest(str(exc)) + + def call_aieye_pipeline(data): try: response = requests.post( diff --git a/backend/src/application/views.py b/backend/src/application/views.py index 40f8d698..7937020c 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -16,8 +16,8 @@ from werkzeug.exceptions import BadRequest as FlaskBadRequest from werkzeug.exceptions import NotFound as FlaskNotFound +from src.application.utils import fetch_aieye_pipelines from src.application import exceptions, schemas, utils -from src.application.enums import GenerateProposalStyle from src.application.enums import ( OperationStatus, SkillLevel, @@ -178,25 +178,11 @@ def post(self): freelancer_name = data["freelancer_name"] job_description = data["job_description"] job_title = data["job_title"] - style_id = data["style_id"] tags = data["tags"] if "tags" in data else "" client_info = data["client_info"] - style_enum = GenerateProposalStyle(style_id) - - if template_id not in current_app.config["AIEYE_API_GENERATE_PROPOSAL_PIPELINE_IDS"]: - raise exceptions.BadRequest(f"Unsupported template_id specified: {template_id}") - freelancer: Freelancer = find_freelancer(freelancer_name) - def get_freelancer_skill_info_fn(freelancer_skill: FreelancerSkill): - return {"name": freelancer_skill.skill.name, "experience": freelancer_skill.experience, - "level": freelancer_skill.level.name} - - freelancer_skills = freelancer.skills - freelancer_skills = list( - map(get_freelancer_skill_info_fn, freelancer_skills)) - openai_response = utils.call_aieye_pipeline( data=json.dumps( {"pipeline_id": template_id, @@ -209,10 +195,8 @@ def get_freelancer_skill_info_fn(freelancer_skill: FreelancerSkill): }, "freelancer": { "freelancer_name": freelancer.name, - "description": freelancer.description, - "skills": freelancer_skills - }, - "style": style_enum.name + "description": freelancer.description + } }} ) ) @@ -519,10 +503,7 @@ def fetch_all_proposals(): return { "result": result, "status_values": ProposalStatus.values(), - "pipelines": { - "HARENDRA": current_app.config["AIEYE_API_HARENDRA_PIPELINE_ID"], - "DIGVIJAJ": current_app.config["AIEYE_API_DIGVIJAJ_PIPELINE_ID"] - } + "pipelines": fetch_aieye_pipelines("propowise") } def _unwrapped_post(self): diff --git a/backend/src/database/dbconfig.py b/backend/src/database/dbconfig.py index ea29fbb2..89566185 100644 --- a/backend/src/database/dbconfig.py +++ b/backend/src/database/dbconfig.py @@ -10,7 +10,7 @@ def get(name: str): _values = { "USER": os.getenv("DB_USER", "python"), "PASSWORD": os.getenv("DB_PASSWORD", "python"), - "URL": os.getenv("DB_HOST", "localhost:5432"), + "URL": os.getenv("DB_HOST", "db:5434"), "DB": os.getenv("DB_NAME", "upwork_tools"), } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5a877493..d7d3ea7f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,6 +23,7 @@ services: - ./logs:/app/backend/logs - ./backend/src:/app/backend/src - ./backend/tests:/app/backend/tests +# command: 'bash -c "sleep 10000"' ports: - '5000:5000' depends_on: From ee6a09c04ecf776fbef798336847f2a1f51505a0 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Wed, 17 Jan 2024 02:26:01 +0100 Subject: [PATCH 07/12] /proposal_templates/ --- backend/src/application/urls.py | 3 +++ backend/src/application/views.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/backend/src/application/urls.py b/backend/src/application/urls.py index e3d014c5..1da35091 100644 --- a/backend/src/application/urls.py +++ b/backend/src/application/urls.py @@ -44,6 +44,9 @@ api_blueprint.add_url_rule( "/generate_proposal/", view_func=views.GenerateProposal().as_view("generate_proposal") ) +api_blueprint.add_url_rule( + "/proposal_templates/", view_func=views.GetProposalTemplates().as_view("proposal_templates") +) public_blueprint = Blueprint( name="public_blueprint", import_name=__name__, url_prefix="/public/" diff --git a/backend/src/application/views.py b/backend/src/application/views.py index 7937020c..92a11e1f 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -204,6 +204,14 @@ def post(self): return jsonify({"response": openai_response}) +class GetProposalTemplates(MethodView): + decorators = [authorized_user_required] + methods = ["GET"] + + def get(self): + return jsonify({"response": fetch_aieye_pipelines("propowise")}) + + class Login(MethodView): methods = ["POST"] decorators = [anonymous_user_required] From c00d5b70d2240c165d890926053409fbd8ad4928 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Wed, 17 Jan 2024 14:40:17 +0100 Subject: [PATCH 08/12] 1gency-app --- Makefile | 24 ++++++++++++------------ README.md | 6 +++--- backend/scripts/recreate_db.sh | 12 ++++++------ backend/src/application/app_core.py | 2 +- docker-compose.dev.yml | 6 +++--- docker-compose.yml | 6 +++--- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index 42f4dd3e..574e52a3 100644 --- a/Makefile +++ b/Makefile @@ -28,9 +28,9 @@ help: start: up: - @docker ps | grep silver-bassoon-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db - @docker ps | grep silver-bassoon-backend > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d backend - @docker ps | grep silver-bassoon-client > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d client + @docker ps | grep 1gency-app-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db + @docker ps | grep 1gency-app-backend > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d backend + @docker ps | grep 1gency-app-client > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d client stop: down: @@ -57,8 +57,8 @@ schema: real_backup: @test -d backend/data || mkdir backend/data @echo ${EXISTING_DUMP_FILE_NAME} > /tmp/current_export_file_name.txt - @docker ps | grep silver-bassoon-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db - @docker exec -it silver-bassoon-db pg_dump -U python upwork_tools -p 5434 > backend/data/`cat /tmp/current_export_file_name.txt` + @docker ps | grep 1gency-app-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db + @docker exec -it 1gency-app-db pg_dump -U python upwork_tools -p 5434 > backend/data/`cat /tmp/current_export_file_name.txt` @( cd backend/data && ln -sf ./`cat /tmp/current_export_file_name.txt` ./latest.bak ) @rm -f /tmp/current_export_file_name.txt @@ -78,17 +78,17 @@ import-v1: stop @if [ -z ${FILE} ]; then ( echo "Usage: make import FILE=some_file_for_import"; exit 1; ) fi @if [ ! -f ${FILE} -a ! -h ${FILE} ]; then ( echo "File ${FILE} not found"; exit 1 ) fi # Оставляем гарантированно только один контейнер, db (совместно с операцией stop). - @docker ps | grep silver-bassoon-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db + @docker ps | grep 1gency-app-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db @echo "Copy db file to container..." - @docker cp -L ${FILE} silver-bassoon-db:/tmp/imported_dump.bak || exit 1 + @docker cp -L ${FILE} 1gency-app-db:/tmp/imported_dump.bak || exit 1 @echo "recreate db in container..." - @docker exec -it silver-bassoon-db psql -U python -d postgres -p 5434 -c 'drop database if exists upwork_tools;' - @docker exec -it silver-bassoon-db psql -U python -d postgres -p 5434 -c 'create database upwork_tools;' + @docker exec -it 1gency-app-db psql -U python -d postgres -p 5434 -c 'drop database if exists upwork_tools;' + @docker exec -it 1gency-app-db psql -U python -d postgres -p 5434 -c 'create database upwork_tools;' @echo "Import db into container..." @echo "psql -U python -d upwork_tools -p 5434 < /tmp/imported_dump.bak" > /tmp/recreate_in_docker.sh @chmod a+x /tmp/recreate_in_docker.sh - @docker cp /tmp/recreate_in_docker.sh silver-bassoon-db:/tmp - @docker exec -it silver-bassoon-db bash /tmp/recreate_in_docker.sh + @docker cp /tmp/recreate_in_docker.sh 1gency-app-db:/tmp + @docker exec -it 1gency-app-db bash /tmp/recreate_in_docker.sh @echo "** data/latest.bak imported into database container. **" @make start @echo "** all containers has been started. **" @@ -126,7 +126,7 @@ last: latest ################################################################################ psql: - @docker exec -it silver-bassoon-db psql -U python upwork_tools -p 5434 + @docker exec -it 1gency-app-db psql -U python upwork_tools -p 5434 bash/backend: @docker compose exec backend /bin/sh diff --git a/README.md b/README.md index 1fdc8e19..f421042d 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,17 @@ `docker-compose logs --tail 10` #### Create the first user in database: - docker exec -it silver-bassoon-backend bash + docker exec -it 1gency-app-backend bash Then execute inside the container: ./commands.py createuser --username=YourUsername --password=YourPassword --is-super #### connect to DB: - docker exec -it silver-bassoon-db psql -U python upwork_tools -p 5434 + docker exec -it 1gency-app-db psql -U python upwork_tools -p 5434 #### create dump: - docker exec -it silver-bassoon-db pg_dump -U python upwork_tools -p 5434 > dump-`date +%Y-%m-%d_%H:%m:%S`.bak + docker exec -it 1gency-app-db pg_dump -U python upwork_tools -p 5434 > dump-`date +%Y-%m-%d_%H:%m:%S`.bak #### Recreating a database inside a DB docker container from a dump diff --git a/backend/scripts/recreate_db.sh b/backend/scripts/recreate_db.sh index 3a3c1236..52e0772d 100755 --- a/backend/scripts/recreate_db.sh +++ b/backend/scripts/recreate_db.sh @@ -12,19 +12,19 @@ fi # Backing up an existing database echo "Backup the db from container..." -docker exec -it silver-bassoon-db pg_dump -U python upwork_tools -p 5434 > ./existing-`date +%Y-%m-%d_%H%M%S`.bak +docker exec -it 1gency-app-db pg_dump -U python upwork_tools -p 5434 > ./existing-`date +%Y-%m-%d_%H%M%S`.bak # Copy new db file to docker container. echo "Copy db file ($1) to container..." -docker cp -L $1 silver-bassoon-db:/tmp/imported_dump.bak || exit 1 +docker cp -L $1 1gency-app-db:/tmp/imported_dump.bak || exit 1 # Recreate database echo "recreate db in container..." -docker exec -it silver-bassoon-db psql -U python -d postgres -p 5434 -c 'drop database if exists upwork_tools;' -docker exec -it silver-bassoon-db psql -U python -d postgres -p 5434 -c 'create database upwork_tools;' +docker exec -it 1gency-app-db psql -U python -d postgres -p 5434 -c 'drop database if exists upwork_tools;' +docker exec -it 1gency-app-db psql -U python -d postgres -p 5434 -c 'create database upwork_tools;' # Import new database echo "Import db into container..." echo "psql -U python -d upwork_tools -p 5434 < /tmp/imported_dump.bak" > /tmp/recreate_in_docker.sh chmod a+x /tmp/recreate_in_docker.sh -docker cp /tmp/recreate_in_docker.sh silver-bassoon-db:/tmp -docker exec -it silver-bassoon-db bash /tmp/recreate_in_docker.sh +docker cp /tmp/recreate_in_docker.sh 1gency-app-db:/tmp +docker exec -it 1gency-app-db bash /tmp/recreate_in_docker.sh diff --git a/backend/src/application/app_core.py b/backend/src/application/app_core.py index d1b2c451..3aadf6bd 100644 --- a/backend/src/application/app_core.py +++ b/backend/src/application/app_core.py @@ -127,6 +127,6 @@ def migrate(app: Flask): def server_started(app: Flask): with app.app_context(): - message = "Silver-Bassoon server started." + message = "1gency-app server started." current_app.logger.info(message) current_app.logger.debug("Server started with ** DEBUG ** configuration") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d7d3ea7f..f21d71ad 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,7 +1,7 @@ version: '3.7' services: client: - container_name: silver-bassoon-client + container_name: 1gency-app-client build: context: client dockerfile: Dockerfile.dev @@ -13,7 +13,7 @@ services: - backend backend: - container_name: silver-bassoon-backend + container_name: 1gency-app-backend build: context: backend args: @@ -32,7 +32,7 @@ services: FLASK_DEBUG: true db: - container_name: silver-bassoon-db + container_name: 1gency-app-db image: postgres:16 environment: POSTGRES_USER: python diff --git a/docker-compose.yml b/docker-compose.yml index 2f37cc83..28bcf2d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.7' services: client: - container_name: silver-bassoon-client + container_name: 1gency-app-client build: context: client ports: @@ -11,7 +11,7 @@ services: - backend backend: - container_name: silver-bassoon-backend + container_name: 1gency-app-backend build: context: backend volumes: @@ -23,7 +23,7 @@ services: command: 'gunicorn --bind 0.0.0.0:5000 app:app --timeout 600 -w 3 --worker-class gevent' db: - container_name: silver-bassoon-db + container_name: 1gency-app-db image: postgres:16 environment: POSTGRES_USER: python From d8558a284313efe3cb3fc94a044756bca6ad6778 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Wed, 17 Jan 2024 14:41:54 +0100 Subject: [PATCH 09/12] docker compose --- Makefile | 18 +++++++++--------- README.md | 14 +++++++------- backend/README.md | 4 ++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Makefile b/Makefile index 574e52a3..92c9f8f4 100644 --- a/Makefile +++ b/Makefile @@ -28,18 +28,18 @@ help: start: up: - @docker ps | grep 1gency-app-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db - @docker ps | grep 1gency-app-backend > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d backend - @docker ps | grep 1gency-app-client > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d client + @docker ps | grep 1gency-app-db > /dev/null || docker compose -f ${DOCKER-COMPOSE-FILE} up -d db + @docker ps | grep 1gency-app-backend > /dev/null || docker compose -f ${DOCKER-COMPOSE-FILE} up -d backend + @docker ps | grep 1gency-app-client > /dev/null || docker compose -f ${DOCKER-COMPOSE-FILE} up -d client stop: down: - @docker-compose -f ${DOCKER-COMPOSE-FILE} stop client - @docker-compose -f ${DOCKER-COMPOSE-FILE} stop backend - @docker-compose -f ${DOCKER-COMPOSE-FILE} stop db + @docker compose -f ${DOCKER-COMPOSE-FILE} stop client + @docker compose -f ${DOCKER-COMPOSE-FILE} stop backend + @docker compose -f ${DOCKER-COMPOSE-FILE} stop db build: stop - docker-compose -f ${DOCKER-COMPOSE-FILE} build --no-cache --force-rm + docker compose -f ${DOCKER-COMPOSE-FILE} build --no-cache --force-rm ################################################################################ @@ -57,7 +57,7 @@ schema: real_backup: @test -d backend/data || mkdir backend/data @echo ${EXISTING_DUMP_FILE_NAME} > /tmp/current_export_file_name.txt - @docker ps | grep 1gency-app-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db + @docker ps | grep 1gency-app-db > /dev/null || docker compose -f ${DOCKER-COMPOSE-FILE} up -d db @docker exec -it 1gency-app-db pg_dump -U python upwork_tools -p 5434 > backend/data/`cat /tmp/current_export_file_name.txt` @( cd backend/data && ln -sf ./`cat /tmp/current_export_file_name.txt` ./latest.bak ) @rm -f /tmp/current_export_file_name.txt @@ -78,7 +78,7 @@ import-v1: stop @if [ -z ${FILE} ]; then ( echo "Usage: make import FILE=some_file_for_import"; exit 1; ) fi @if [ ! -f ${FILE} -a ! -h ${FILE} ]; then ( echo "File ${FILE} not found"; exit 1 ) fi # Оставляем гарантированно только один контейнер, db (совместно с операцией stop). - @docker ps | grep 1gency-app-db > /dev/null || docker-compose -f ${DOCKER-COMPOSE-FILE} up -d db + @docker ps | grep 1gency-app-db > /dev/null || docker compose -f ${DOCKER-COMPOSE-FILE} up -d db @echo "Copy db file to container..." @docker cp -L ${FILE} 1gency-app-db:/tmp/imported_dump.bak || exit 1 @echo "recreate db in container..." diff --git a/README.md b/README.md index f421042d..2923c461 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ #### docker-compose: -`docker-compose up` or `docker-compose up -d` +`docker compose up` or `docker compose up -d` -`docker-compose stop` +`docker compose stop` -`docker-compose build` +`docker compose build` -`docker-compose logs` or `docker-compose logs backend` +`docker compose logs` or `docker compose logs backend` -`docker-compose logs --tail 10` +`docker compose logs --tail 10` #### Create the first user in database: docker exec -it 1gency-app-backend bash @@ -46,10 +46,10 @@ For further info, click either `backend` or `client`. Thanks. ```bash # Build and start the containers: -sudo docker-compose up -d --build +sudo docker compose up -d --build # Then create a user: -sudo docker-compose exec backend ./commands.py createuser --username admin --password admin --is-super +sudo docker compose exec backend ./commands.py createuser --username admin --password admin --is-super # Obtain your auth token: token=$(curl --request POST \ diff --git a/backend/README.md b/backend/README.md index c63359db..522f31d0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -13,8 +13,8 @@ See [corresponding README](./src/parser/README.md) for details. ### Backend: About migrations: 1. Migrations run automatically when you run the ./app.py file 3. Execute - docker-compose build - docker-compose up + docker compose build + docker compose up Refer to `./app.py` for insights. ### Backend: Migrations workflow: From f816805148399e1b9ac33b1ffa70d17ebb3e4058 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Tue, 23 Jan 2024 19:31:27 +0100 Subject: [PATCH 10/12] Define docker compose file in .env, update help entries --- Makefile | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 92c9f8f4..a2eb1ab8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ include make_commands.mk -DOCKER-COMPOSE-FILE=./docker-compose.yml +include .env +DOCKER-COMPOSE-FILE=$(DOCKER_COMPOSE_FILE) + NOW := `date +%Y-%m-%d_%H%M%S` EXISTING_DUMP_FILE_NAME := existing-${NOW}.bak @@ -10,21 +12,23 @@ all: help help: @echo "Usage:" - @echo " make build - rebuild all docker containers" - @echo " make up - start all docker containers with existing data" - @echo " make down - stop all executed docker containers" - @echo " make export - export existing data to dump for backup" - @echo " make backup - the same as above" - @echo " make dump - the same as above" - @echo " make import FILE=file_name - import file_name into database container (Be careful! Existing data backup does not performed automatically! Use make backup together!)" - @echo " make latest - import data/latest.bak into database container (Be careful! Existing data backup does not performed automatically! Use make backup together!)" - @echo " make last - the same as above" - @echo " make schema - auto-generation database schema from json file (see backend/src/parser/README.md)" - @echo " make psql - connect to the database using psql" - @echo " make bash/backend - open a shell in the backend container" - @echo " make logs/backend - view logs for the backend container" - @echo " make upload-skills - upload skills using the provided USER and PASS" - + @echo " make build - Rebuild all docker containers." + @echo " make up - Start all docker containers with existing data." + @echo " make down - Stop all running docker containers." + @echo " make export - Export existing data to a dump file for backup." + @echo " make backup - Perform a backup of existing data (alias for export)." + @echo " make dump - Alias for export." + @echo " make import FILE= - Import into the database container. Caution: existing data backup is not performed automatically! Use 'make backup' together." + @echo " make latest - Import the latest backup (data/latest.bak) into the database container. Caution: existing data backup is not performed automatically! Use 'make backup' together." + @echo " make last - Alias for latest." + @echo " make schema - Auto-generate database schema from a JSON file (see backend/src/parser/README.md)." + @echo " make psql - Connect to the database using psql." + @echo " make bash/backend - Open a shell in the backend container." + @echo " make logs/backend - View logs for the backend container." + @echo " make upload-skills - Upload skills using the provided USER and PASS." + @echo " make db-migrate - Run database migrations." + # Add new entries or modify existing ones below + # @echo " make - ." start: up: From df12c7be7a4afe0f3ffa39fd31f7c0d98dddbf82 Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Tue, 23 Jan 2024 19:53:54 +0100 Subject: [PATCH 11/12] Get tenant_id from user --- backend/src/application/app_core.py | 9 ++++----- backend/src/database/tenant.py | 20 +++++++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/backend/src/application/app_core.py b/backend/src/application/app_core.py index 77d834ee..96c9a47a 100644 --- a/backend/src/application/app_core.py +++ b/backend/src/application/app_core.py @@ -44,11 +44,10 @@ def logger(self) -> logging.Logger: def _store_tenant_id_in_context(): g.tenant_id = None - tenant_id = request.headers.get("X-Tenant-ID") + tenant_id = g.user.company_id if g.user else None + logger.info(f"Tenant id: {tenant_id}") if tenant_id: g.tenant_id = tenant_id - else: - g.tenant_id = 2 def _store_authorized_user_in_context(): @@ -71,7 +70,7 @@ def _store_authorized_user_in_context(): try: user_id = User.decode_auth_token(token) - g.user = User.query.get(user_id) + g.user = User.query.get_without_tenant(user_id) except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): g.token_is_deprecated = True return @@ -93,8 +92,8 @@ def invalid_api_usage(exc): @app.before_request def before_request(): - _store_tenant_id_in_context() _store_authorized_user_in_context() + _store_tenant_id_in_context() # enable CORS CORS(app, resources={r"/*": {"origins": config.FLASK_HTTP_ORIGIN}}) diff --git a/backend/src/database/tenant.py b/backend/src/database/tenant.py index 77b7892b..e3001849 100644 --- a/backend/src/database/tenant.py +++ b/backend/src/database/tenant.py @@ -7,42 +7,48 @@ from src.database.db import db +logger = logging.getLogger() class TenantScopedQuery(BaseQuery): def get(self, id): # Automatically add the company_id filter when retrieving a single record by ID + logger.info(f"Getting model with id: {id} and company_id: {g.tenant_id}") return super(TenantScopedQuery, self).filter_by(id=id, company_id=g.tenant_id).first() + def get_without_tenant(self, id): + logger.info(f"Getting model with id: {id} without company_id filter") + return super(TenantScopedQuery, self).get(id) + def filter(self, *criteria): # Call the original filter method with the provided criteria query = super(TenantScopedQuery, self).filter(*criteria) - logging.error(f"Query: {query}") + logger.info(f"Query: {query}") - logging.error(f"Tenant id: {g.tenant_id}") + logger.info(f"Tenant id: {g.tenant_id}") # Manually construct the tenant filter condition tenant_condition = (self._entity_zero().class_.company_id == g.tenant_id,) - logging.error(f"Tenant condition: {tenant_condition}") + logger.info(f"Tenant condition: {tenant_condition}") # Add the tenant condition to the query's criterion list query._where_criteria = query._where_criteria + tenant_condition - logging.error(f"Query with tenant condition: {query}") + logger.info(f"Query with tenant condition: {query}") return query def filter_by(self, **kwargs): - logging.error(f"Filter by kwargs: {kwargs}") + logger.info(f"Filter by kwargs: {kwargs}") return super(TenantScopedQuery, self).filter_by(**kwargs) def all(self): raise ValueError("Cannot use all() directly. Use all_for_tenant() instead.") def all_for_tenant(self): - logging.error(f"All for tenant id: {g.tenant_id}") + logger.info(f"All for tenant id: {g.tenant_id}") # Automatically add the company_id filter when retrieving all records return super(TenantScopedQuery, self).filter_by(company_id=g.tenant_id)._iter().all() @@ -61,7 +67,7 @@ class TenantScopedModel(db.Model): # Override the save method to automatically set company_id def save(self, *args, **kwargs): # Always set company_id to the current tenant's ID, regardless of user input - logging.info(f"Saving model: {self} with company_id: {g.tenant_id}") + logger.info(f"Saving model: {self} with company_id: {g.tenant_id}") self.company_id = g.tenant_id super().save(*args, **kwargs) From eeba0e1e1c74f76dfe891fc040bead8fad7d564f Mon Sep 17 00:00:00 2001 From: Khvatov Dmitry Date: Tue, 23 Jan 2024 21:20:40 +0100 Subject: [PATCH 12/12] Create company when user signs in for the first time --- backend/src/database/generated_models.py | 18 +--- backend/src/database/tenant.py | 4 +- backend/src/database/wrapped_models.py | 29 ++++-- .../src/migrations/versions/1accb1c33533_.py | 90 +++++++++++++++++++ backend/src/parser/json_schema.py | 63 +++++-------- 5 files changed, 139 insertions(+), 65 deletions(-) create mode 100644 backend/src/migrations/versions/1accb1c33533_.py diff --git a/backend/src/database/generated_models.py b/backend/src/database/generated_models.py index bfa5ccf9..04c69964 100644 --- a/backend/src/database/generated_models.py +++ b/backend/src/database/generated_models.py @@ -16,7 +16,6 @@ class User(TenantScopedModel): """ System users (who can log in from the WEB) """ __tablename__ = "users" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Идентификатор записи в таблице учета времени, primary key id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Username @@ -57,7 +56,6 @@ class UserVisibility(TenantScopedModel): Админы, разумеется, видят все. """ __tablename__ = "user_visibilities" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Идентификатор записи в таблице id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Кто может видеть (к кому относится эта запись) @@ -70,7 +68,6 @@ class Freelancer(TenantScopedModel): """ Зарегистрированные в системе фрилансеры """ __tablename__ = "freelancers" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Уникальный идентификатор записи в таблице фрилансеров id = db.Column(db.Integer, primary_key=True, autoincrement=True) # User-uploaded image, normalized to png of known size. @@ -111,7 +108,6 @@ class Candidate(TenantScopedModel): """ Candidates (people who are not yet freelancers) """ __tablename__ = "candidates" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # id id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Candidate full name @@ -127,7 +123,6 @@ class Manager(TenantScopedModel): """ Зарегистрированные в системе менеджеры """ __tablename__ = "managers" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) # Уникальный идентификатор записи в таблице id = db.Column(db.Integer, primary_key=True, autoincrement=True) # upwork код данного менеджера @@ -142,7 +137,6 @@ class Roles(TenantScopedModel): """ Роли пользователей в системе """ __tablename__ = "roles" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Код данной роли, константа. role_code = db.Column(db.String(64), nullable=False) @@ -154,7 +148,6 @@ class UsersRoles(TenantScopedModel): """ Таблица отношений многие-ко-многим между пользователями и ролями """ __tablename__ = "users_roles" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Идентификатор пользователя, которому назначена данная роль user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete='CASCADE'), nullable=False) @@ -166,7 +159,6 @@ class Questions(TenantScopedModel): """ Possible freelancer question. """ __tablename__ = "questions" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Question detailed description description = db.Column(db.Text) @@ -179,7 +171,6 @@ class FreelancersQuestionsAnswers(TenantScopedModel): """ Relation between Freelancer Question answers and questions. """ __tablename__ = "freelancers_questions_answers" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) freelancer_id = db.Column(db.Integer, db.ForeignKey(Freelancer.id, ondelete='CASCADE'), nullable=False) freelancer = db.relationship("src.database.wrapped_models.Freelancer", back_populates="questions_answers", uselist=False, passive_deletes=True) @@ -193,7 +184,6 @@ class SkillCategory(TenantScopedModel): """ Freelancer skill category: groups similar skills together. """ __tablename__ = "skill_categories" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Category name (short, descriptive title) name = db.Column(db.String(127), nullable=False) @@ -209,7 +199,6 @@ class Skill(TenantScopedModel): """ Possible freelancer skill. """ __tablename__ = "skills" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # Corresponding Freelancer-Skill matches freelancer_skills = db.relationship("FreelancerSkill", back_populates="skill", uselist=True, cascade="all, delete-orphan") @@ -227,7 +216,6 @@ class CandidateSkill(TenantScopedModel): """ Candidate skills (many-to-many Candidate to Skill matching) """ __tablename__ = "candidate_skills" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) candidate_id = db.Column(db.Integer, db.ForeignKey(Candidate.id, ondelete='CASCADE'), nullable=False) candidate = db.relationship("Candidate", back_populates="skills", uselist=False, passive_deletes=True) @@ -243,7 +231,6 @@ class FreelancerSkill(TenantScopedModel): """ Freelancer skills (many-to-many Freelancer to Skill matching) """ __tablename__ = "freelancer_skills" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) # If NULL, the skill belongs to all team members freelancer_id = db.Column(db.Integer, db.ForeignKey(Freelancer.id, ondelete='CASCADE'), nullable=True) @@ -261,7 +248,6 @@ class Skill2SkillCategory(TenantScopedModel): """ Skill to skill category (many-to-many intermediate table) """ __tablename__ = "skill_2_skill_category" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) skill_category_id = db.Column(db.Integer, db.ForeignKey(SkillCategory.id, ondelete='CASCADE'), nullable=False) skill_id = db.Column(db.Integer, db.ForeignKey(Skill.id, ondelete='CASCADE'), nullable=False) @@ -271,16 +257,14 @@ class Question2SkillCategory(TenantScopedModel): """ Question to skill category (many-to-many intermediate table) """ __tablename__ = "question_2_skill_category" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) skill_category_id = db.Column(db.Integer, db.ForeignKey(SkillCategory.id, ondelete='CASCADE'), nullable=False) question_id = db.Column(db.Integer, db.ForeignKey(Questions.id, ondelete='CASCADE'), nullable=False) -class Company(TenantScopedModel): +class Company(db.Model): """ Represents a company in the system """ __tablename__ = "company" - company_id = db.Column(db.Integer, db.ForeignKey('company.id', ondelete='SET NULL'), server_default="1", nullable=False) id = db.Column(db.Integer, primary_key=True, autoincrement=True) name = db.Column(db.String(254), nullable=False) diff --git a/backend/src/database/tenant.py b/backend/src/database/tenant.py index e3001849..16b08cc1 100644 --- a/backend/src/database/tenant.py +++ b/backend/src/database/tenant.py @@ -9,6 +9,7 @@ logger = logging.getLogger() + class TenantScopedQuery(BaseQuery): def get(self, id): @@ -69,7 +70,8 @@ def save(self, *args, **kwargs): # Always set company_id to the current tenant's ID, regardless of user input logger.info(f"Saving model: {self} with company_id: {g.tenant_id}") self.company_id = g.tenant_id - super().save(*args, **kwargs) + db.session.add(self) + db.session.commit() def update(self, **kwargs): if 'company_id' in kwargs: diff --git a/backend/src/database/wrapped_models.py b/backend/src/database/wrapped_models.py index 37daf292..5c4ace67 100644 --- a/backend/src/database/wrapped_models.py +++ b/backend/src/database/wrapped_models.py @@ -1,15 +1,26 @@ from __future__ import annotations import datetime +import logging import jwt -from flask import url_for +from flask import url_for, g from passlib.context import CryptContext from sqlalchemy import func + from src.application import config, exceptions from src.database import generated_models from src.database.db import db +logger = logging.getLogger() + + +def create_company(username): + company = generated_models.Company(name=username) + db.session.add(company) + db.session.commit() + return company.id + class User(generated_models.User): """System users (who can log in from the WEB)""" @@ -31,24 +42,26 @@ def get_password_context(cls): return cls._pwcontext @classmethod - def create_user(cls, username: str, password: str, is_admin: bool = False, **kwargs): - # Проверяем, что нет пользователя с таким же username + def create_user(cls, username: str, password: str, email: str, is_admin: bool = False, **kwargs): + logger.info(f"Creating user with email: {email}") existing = User.query.filter( - func.lower(User.username) == func.lower(username) + func.lower(User.email) == func.lower(email) ).count() if existing: raise exceptions.BadRequest( - "This username is already occupied, try another please." + f"User with email {email} already exists" ) + g.tenant_id = create_company(username) + user = User( username=username, password=cls.encode_password(password), + email=email, is_admin=is_admin, - **kwargs, + **kwargs ) - db.session.add(user) - db.session.commit() + user.save() return user @classmethod diff --git a/backend/src/migrations/versions/1accb1c33533_.py b/backend/src/migrations/versions/1accb1c33533_.py new file mode 100644 index 00000000..b7885600 --- /dev/null +++ b/backend/src/migrations/versions/1accb1c33533_.py @@ -0,0 +1,90 @@ +"""empty message + +Revision ID: 1accb1c33533 +Revises: 2319bc3735ac +Create Date: 2024-01-23 20:18:46.524879 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1accb1c33533' +down_revision = '2319bc3735ac' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_candidate_skills_company_id'), 'candidate_skills', ['company_id'], unique=False) + op.drop_constraint('candidate_skills_company_id_fkey', 'candidate_skills', type_='foreignkey') + op.create_index(op.f('ix_candidates_company_id'), 'candidates', ['company_id'], unique=False) + op.drop_constraint('candidates_company_id_fkey', 'candidates', type_='foreignkey') + op.drop_constraint('company_company_id_fkey', 'company', type_='foreignkey') + op.drop_column('company', 'company_id') + op.create_index(op.f('ix_freelancer_skills_company_id'), 'freelancer_skills', ['company_id'], unique=False) + op.drop_constraint('freelancer_skills_company_id_fkey', 'freelancer_skills', type_='foreignkey') + op.create_index(op.f('ix_freelancers_company_id'), 'freelancers', ['company_id'], unique=False) + op.drop_constraint('freelancers_company_id_fkey', 'freelancers', type_='foreignkey') + op.create_index(op.f('ix_freelancers_questions_answers_company_id'), 'freelancers_questions_answers', ['company_id'], unique=False) + op.drop_constraint('freelancers_questions_answers_company_id_fkey', 'freelancers_questions_answers', type_='foreignkey') + op.create_index(op.f('ix_managers_company_id'), 'managers', ['company_id'], unique=False) + op.drop_constraint('managers_company_id_fkey', 'managers', type_='foreignkey') + op.create_index(op.f('ix_question_2_skill_category_company_id'), 'question_2_skill_category', ['company_id'], unique=False) + op.drop_constraint('question_2_skill_category_company_id_fkey', 'question_2_skill_category', type_='foreignkey') + op.create_index(op.f('ix_questions_company_id'), 'questions', ['company_id'], unique=False) + op.drop_constraint('questions_company_id_fkey', 'questions', type_='foreignkey') + op.create_index(op.f('ix_roles_company_id'), 'roles', ['company_id'], unique=False) + op.drop_constraint('roles_company_id_fkey', 'roles', type_='foreignkey') + op.create_index(op.f('ix_skill_2_skill_category_company_id'), 'skill_2_skill_category', ['company_id'], unique=False) + op.drop_constraint('skill_2_skill_category_company_id_fkey', 'skill_2_skill_category', type_='foreignkey') + op.create_index(op.f('ix_skill_categories_company_id'), 'skill_categories', ['company_id'], unique=False) + op.drop_constraint('skill_categories_company_id_fkey', 'skill_categories', type_='foreignkey') + op.create_index(op.f('ix_skills_company_id'), 'skills', ['company_id'], unique=False) + op.drop_constraint('skills_company_id_fkey', 'skills', type_='foreignkey') + op.create_index(op.f('ix_user_visibilities_company_id'), 'user_visibilities', ['company_id'], unique=False) + op.drop_constraint('user_visibilities_company_id_fkey', 'user_visibilities', type_='foreignkey') + op.create_index(op.f('ix_users_company_id'), 'users', ['company_id'], unique=False) + op.drop_constraint('users_company_id_fkey', 'users', type_='foreignkey') + op.create_index(op.f('ix_users_roles_company_id'), 'users_roles', ['company_id'], unique=False) + op.drop_constraint('users_roles_company_id_fkey', 'users_roles', type_='foreignkey') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key('users_roles_company_id_fkey', 'users_roles', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_users_roles_company_id'), table_name='users_roles') + op.create_foreign_key('users_company_id_fkey', 'users', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_users_company_id'), table_name='users') + op.create_foreign_key('user_visibilities_company_id_fkey', 'user_visibilities', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_user_visibilities_company_id'), table_name='user_visibilities') + op.create_foreign_key('skills_company_id_fkey', 'skills', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_skills_company_id'), table_name='skills') + op.create_foreign_key('skill_categories_company_id_fkey', 'skill_categories', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_skill_categories_company_id'), table_name='skill_categories') + op.create_foreign_key('skill_2_skill_category_company_id_fkey', 'skill_2_skill_category', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_skill_2_skill_category_company_id'), table_name='skill_2_skill_category') + op.create_foreign_key('roles_company_id_fkey', 'roles', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_roles_company_id'), table_name='roles') + op.create_foreign_key('questions_company_id_fkey', 'questions', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_questions_company_id'), table_name='questions') + op.create_foreign_key('question_2_skill_category_company_id_fkey', 'question_2_skill_category', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_question_2_skill_category_company_id'), table_name='question_2_skill_category') + op.create_foreign_key('managers_company_id_fkey', 'managers', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_managers_company_id'), table_name='managers') + op.create_foreign_key('freelancers_questions_answers_company_id_fkey', 'freelancers_questions_answers', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_freelancers_questions_answers_company_id'), table_name='freelancers_questions_answers') + op.create_foreign_key('freelancers_company_id_fkey', 'freelancers', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_freelancers_company_id'), table_name='freelancers') + op.create_foreign_key('freelancer_skills_company_id_fkey', 'freelancer_skills', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_freelancer_skills_company_id'), table_name='freelancer_skills') + op.add_column('company', sa.Column('company_id', sa.INTEGER(), server_default=sa.text('1'), autoincrement=False, nullable=False)) + op.create_foreign_key('company_company_id_fkey', 'company', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.create_foreign_key('candidates_company_id_fkey', 'candidates', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_candidates_company_id'), table_name='candidates') + op.create_foreign_key('candidate_skills_company_id_fkey', 'candidate_skills', 'company', ['company_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_candidate_skills_company_id'), table_name='candidate_skills') + # ### end Alembic commands ### diff --git a/backend/src/parser/json_schema.py b/backend/src/parser/json_schema.py index 4e9b4484..86478ca9 100644 --- a/backend/src/parser/json_schema.py +++ b/backend/src/parser/json_schema.py @@ -33,7 +33,7 @@ def __init__(self) -> None: self.ident_level = 0 def parse( - self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True + self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True ) -> None: if not isinstance(source, dict): raise ValueError("Item.parse(): element is not dict") @@ -49,7 +49,7 @@ def parse( raise ValueError("Item::parse(), the item with empty name") def _parse_known_children( - self, source: Union[List[StrKeyDict], StrKeyDict] + self, source: Union[List[StrKeyDict], StrKeyDict] ) -> None: for key, descriptor in self._known_children.items(): if descriptor.get("handler_class"): @@ -93,7 +93,7 @@ def __init__(self): self.schema = MarshmallowSchema() def parse( - self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True + self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True ) -> None: super().parse(source, raise_exception) if self.MANDATORY_DB_FIELDS: @@ -128,9 +128,9 @@ def get_db_model_attributes_as_string(self, exclude: list = None): else: quote = '"' if ( - name == "default" - and "def_quotes" in self.db - and not self.db["def_quotes"] + name == "default" + and "def_quotes" in self.db + and not self.db["def_quotes"] ): quote = "" value = quote + value + quote @@ -268,10 +268,10 @@ def write_model(self, file: formatter.File): # Атрибуты - как поля базы данных. string += ( - self.get_db_model_attributes_as_string( - exclude=["field_type", "reference", "ref_quotes", "ondelete"] - ) - + ")" + self.get_db_model_attributes_as_string( + exclude=["field_type", "reference", "ref_quotes", "ondelete"] + ) + + ")" ) file.write(string) @@ -298,16 +298,16 @@ def write_model(self, file: formatter.File): # порядке, т.к. он определяет one-to-one (по умолчанию - False). string += ( - self.get_db_model_attributes_as_string( - exclude=[ - "reference", - "back_populates", - "secondary", - "ref_quotes", - "secondary_quotes", - ] - ) - + ")" + self.get_db_model_attributes_as_string( + exclude=[ + "reference", + "back_populates", + "secondary", + "ref_quotes", + "secondary_quotes", + ] + ) + + ")" ) file.write(string) @@ -342,7 +342,7 @@ class EntityField(Item): ] def parse( - self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True + self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True ) -> None: """Parse для DBField абстрактно, оно распознает все имеющиеся поля в DBModel.""" @@ -352,21 +352,6 @@ def parse( if not isinstance(source, list) and raise_exception: raise ValueError(f"{self.parent.name}, fields is not list") - company_field = ForeignKeyField() - company_field.parent = self - company_field.parse({ - "name": "company_id", - "type": "ForeignKey", - "db": { - "field_type": "Integer", - "reference": "'company.id'", - "ondelete": "SET NULL", - "server_default": "1", - "nullable": False - }, - }, raise_exception=False) - self.parent.fields = [company_field] - for one_field_dict in source: field_type = one_field_dict.get("type") if not field_type and raise_exception: @@ -401,7 +386,7 @@ def __init__(self) -> None: self.imports: Optional[Union[str, List[str]]] = None def parse( - self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True + self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True ) -> None: super().parse(source, raise_exception=False) if not self.name and self.parent: @@ -459,7 +444,7 @@ def __init__(self) -> None: } def parse( - self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True + self, source: Union[List[StrKeyDict], StrKeyDict], raise_exception=True ) -> None: if not isinstance(source, dict): raise ValueError("Entity parse: source is not dict.") @@ -519,7 +504,7 @@ def write_marshmallow_schema(self, file: formatter.File) -> None: file.level_down() def _write_comment_and_docstring( - self, file: formatter.File, comment: Optional[StrOrStrList] + self, file: formatter.File, comment: Optional[StrOrStrList] ) -> None: if comment: if isinstance(comment, str):