diff --git a/Makefile b/Makefile index 42f4dd3e..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,36 +12,38 @@ 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: - @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: - @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,8 +61,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 +82,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 +130,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..2923c461 100644 --- a/README.md +++ b/README.md @@ -7,28 +7,28 @@ #### 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 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 @@ -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: 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/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/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 a368bb58..96c9a47a 100644 --- a/backend/src/application/app_core.py +++ b/backend/src/application/app_core.py @@ -8,12 +8,12 @@ from flask_cors import CORS from flask_mail import Mail -# Добавление автоматически сгенерированных моделей. +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 @@ -42,6 +42,14 @@ def logger(self) -> logging.Logger: return lgr +def _store_tenant_id_in_context(): + g.tenant_id = None + 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 + + def _store_authorized_user_in_context(): # Store authorized user in the context, if any g.user = None @@ -62,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 @@ -85,6 +93,7 @@ def invalid_api_usage(exc): @app.before_request def before_request(): _store_authorized_user_in_context() + _store_tenant_id_in_context() # enable CORS CORS(app, resources={r"/*": {"origins": config.FLASK_HTTP_ORIGIN}}) @@ -107,22 +116,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.") @@ -139,6 +140,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/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 17486fe9..055897dd 100644 --- a/backend/src/application/schemas.py +++ b/backend/src/application/schemas.py @@ -9,7 +9,7 @@ ) from werkzeug.exceptions import BadRequest -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 @@ -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/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/urls.py b/backend/src/application/urls.py index fb625caf..7ae08fea 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/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 f2f0a0b8..1f1c878a 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -17,8 +17,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, @@ -36,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 @@ -303,25 +304,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, @@ -334,10 +321,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 + } }} ) ) @@ -345,6 +330,14 @@ def get_freelancer_skill_info_fn(freelancer_skill: FreelancerSkill): 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] @@ -392,25 +385,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): - 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") - ) - + 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.tenant_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)) @@ -462,7 +446,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 = [ @@ -479,7 +463,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()} # -------------------------------------------------------------------------------------- @@ -501,6 +485,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: @@ -570,6 +555,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) ) @@ -578,6 +564,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) ) @@ -587,6 +574,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)) ) @@ -623,12 +611,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()) @@ -644,10 +636,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): @@ -799,7 +788,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) @@ -821,7 +810,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) @@ -830,7 +819,7 @@ def _update_existing_element(self, existing_element: db.Model, data: dict): 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): @@ -849,6 +838,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")) ) @@ -866,13 +856,13 @@ def _unwrapped_get(self): if g.user.is_admin: # Админ видит всех пользователей системы. - query = User.query.all() + query = User.query.all_for_tenant() 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_( @@ -961,7 +951,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 +1048,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/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/backend/src/database/generated_models.py b/backend/src/database/generated_models.py index fdfd6214..04c69964 100644 --- a/backend/src/database/generated_models.py +++ b/backend/src/database/generated_models.py @@ -8,11 +8,11 @@ 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" @@ -48,7 +48,7 @@ class User(db.Model): email_confirmation_expired = db.Column(db.DateTime(), default=None, nullable=True) -class UserVisibility(db.Model): +class UserVisibility(TenantScopedModel): """ Кто из пользователей системы кого может видеть. Пользователь - если он не админ - может видеть только тех, кто здесь указан. Остальных участников данный пользователь не видит. Это позволяет гибко настроить @@ -64,7 +64,7 @@ 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" @@ -104,7 +104,7 @@ 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" @@ -119,7 +119,7 @@ class Candidate(db.Model): url = db.Column(URLType, nullable=True, unique=True) -class Manager(db.Model): +class Manager(TenantScopedModel): """ Зарегистрированные в системе менеджеры """ __tablename__ = "managers" @@ -133,7 +133,7 @@ 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" @@ -144,7 +144,7 @@ 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" @@ -155,7 +155,7 @@ 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" @@ -167,7 +167,7 @@ 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" @@ -180,7 +180,7 @@ 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" @@ -195,7 +195,7 @@ 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" @@ -212,7 +212,7 @@ 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" @@ -227,7 +227,7 @@ 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" @@ -244,7 +244,7 @@ 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" @@ -253,10 +253,18 @@ class Skill2SkillCategory(db.Model): 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" 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(db.Model): + """ Represents a company in the system + """ + __tablename__ = "company" + 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..16b08cc1 --- /dev/null +++ b/backend/src/database/tenant.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging + +from flask import g +from flask_sqlalchemy import BaseQuery + +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) + + logger.info(f"Query: {query}") + + 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,) + + 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 + + logger.info(f"Query with tenant condition: {query}") + + return query + + def filter_by(self, **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): + 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() + + 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 + logger.info(f"Saving model: {self} with company_id: {g.tenant_id}") + self.company_id = g.tenant_id + db.session.add(self) + db.session.commit() + + 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/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/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 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/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..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,8 +352,6 @@ def parse( if not isinstance(source, list) and raise_exception: raise ValueError(f"{self.parent.name}, fields is not list") - self.parent.fields = [] - for one_field_dict in source: field_type = one_field_dict.get("type") if not field_type and raise_exception: @@ -388,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: @@ -446,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.") @@ -464,7 +462,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) @@ -506,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): 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..0d1defe0 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: @@ -24,6 +24,7 @@ services: - ./backend:/app/backend - ./backend/src:/app/backend/src - ./backend/tests:/app/backend/tests +# command: 'bash -c "sleep 10000"' ports: - '5000:5000' depends_on: @@ -33,7 +34,7 @@ services: FLASK_DEBUG: true db: - container_name: silver-bassoon-db + container_name: 1gency-app-db image: postgres:16 environment: POSTGRES_USER: python @@ -43,7 +44,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: diff --git a/docker-compose.yml b/docker-compose.yml index e01bf014..099d1b20 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: @@ -24,7 +24,7 @@ services: env_file: .env db: - container_name: silver-bassoon-db + container_name: 1gency-app-db image: postgres:16 environment: POSTGRES_USER: python