diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..692c4aac --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +FLASK_APP=app.py +FLASK_RUN_HOST=0.0.0.0 +FLASK_DEBUG=true +DB_HOST=db:5434 +SECRET_KEY=muiguyoNoo0kaip1nah3phoi1ouFeuc5aexeeKai2aeY9pohqu5Enohthohng4to + +MAIL_SERVER="localhost" +MAIL_PORT=465 +MAIL_USERNAME="" +MAIL_PASSWORD="" +MAIL_DEFAULT_SENDER="" +MAIL_USE_TLS=false +MAIL_USE_SSL=true diff --git a/backend/.dockerignore b/backend/.dockerignore index d7261565..c4031c83 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -7,3 +7,4 @@ __pycache__ data venv logs +client_secret_apps.json \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index fab5f73f..ac971920 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,11 +2,6 @@ FROM python:3.9-alpine WORKDIR /app/backend ARG REQUIREMENTS_FILE -ENV FLASK_APP=app.py -ENV FLASK_RUN_HOST=0.0.0.0 -ENV FLASK_DEBUG=true -ENV DB_HOST=db:5434 -ENV SECRET_KEY=muiguyoNoo0kaip1nah3phoi1ouFeuc5aexeeKai2aeY9pohqu5Enohthohng4to COPY requirements.txt requirements.txt COPY requirements.dev.txt requirements.dev.txt diff --git a/backend/requirements.txt b/backend/requirements.txt index 9ecee45f..c2ce429c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,3 +22,5 @@ sqlalchemy-utils == 0.39.0 werkzeug == 2.3.7 gevent psycogreen +flask_oauthlib==0.9.6 +Flask-Mail == 0.9.1 \ No newline at end of file diff --git a/backend/src/application/app_core.py b/backend/src/application/app_core.py index 63686284..a368bb58 100644 --- a/backend/src/application/app_core.py +++ b/backend/src/application/app_core.py @@ -4,8 +4,9 @@ import flask_migrate import jwt -from flask import Flask, current_app, g, jsonify, request +from flask import Flask, current_app, g, request, jsonify from flask_cors import CORS +from flask_mail import Mail # Добавление автоматически сгенерированных моделей. try: @@ -22,6 +23,7 @@ from . import config, exceptions from .urls import api_blueprint, public_blueprint +from .oauth import oauth_blueprint class FlaskLogged(Flask): @@ -50,11 +52,11 @@ def _store_authorized_user_in_context(): if not auth_header: return - splitted = [item.strip() for item in auth_header.split(" ")] - if not splitted or splitted[0].upper() not in ["BEARER", "TOKEN", "JWT"]: + split = [item.strip() for item in auth_header.split(" ")] + if not split or split[0].upper() not in ["BEARER", "TOKEN", "JWT"]: return - token = splitted[-1] + token = split[-1] if not token: return @@ -86,12 +88,14 @@ def before_request(): # enable CORS CORS(app, resources={r"/*": {"origins": config.FLASK_HTTP_ORIGIN}}) - + mail = Mail() core_db.init_app(app) + mail.init_app(app) with app.app_context(): app.register_blueprint(api_blueprint) app.register_blueprint(public_blueprint) + app.register_blueprint(oauth_blueprint, url_prefix='/oauth') try: with app.config["AIEYE_CREDENTIALS_PATH"].open() as file: diff --git a/backend/src/application/config.py b/backend/src/application/config.py index bb2320b8..719d85e0 100644 --- a/backend/src/application/config.py +++ b/backend/src/application/config.py @@ -18,14 +18,24 @@ FLASK_RUN_HOST = "0.0.0.0" FLASK_HTTP_ORIGIN = "*" +MAIL_SERVER = "localhost" +MAIL_PORT = 465 +MAIL_USERNAME = "" +MAIL_PASSWORD = "" +MAIL_DEFAULT_SENDER = "" +MAIL_USE_TLS = False +MAIL_USE_SSL = True + ROOT_PATH = Path(__file__).parent.parent.parent.resolve() AIEYE_CREDENTIALS_PATH = (ROOT_PATH / "aieye_credentials.json").resolve() +GOOGLE_CLIENT_SECRET_PATH = (ROOT_PATH / "client_secret_apps.json").resolve() def init_config(): global DEBUG, SQLALCHEMY_DATABASE_URI, JWT_EXPIRATION_DELTA, SECRET_KEY global JSONIFY_MIMETYPE, FLASK_RUN_PORT, FLASK_RUN_HOST, FLASK_HTTP_ORIGIN + global MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_DEFAULT_SENDER, MAIL_USE_SSL, MAIL_USE_TLS DEBUG = os.getenv("FLASK_DEBUG", "true").upper() == "TRUE" SQLALCHEMY_DATABASE_URI = ( @@ -45,3 +55,9 @@ def init_config(): FLASK_RUN_PORT = int(os.getenv("FLASK_RUN_PORT", "5000") or "5000") FLASK_RUN_HOST = os.getenv("FLASK_RUN_HOST", "0.0.0.0") FLASK_HTTP_ORIGIN = os.getenv("FLASK_HTTP_ORIGIN", "*") + + MAIL_SERVER = os.getenv("MAIL_SERVER", "") + MAIL_PORT = os.getenv("MAIL_PORT", 465) + MAIL_USERNAME = os.getenv("MAIL_USERNAME", "") + MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "") + MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER", "") diff --git a/backend/src/application/oauth.py b/backend/src/application/oauth.py new file mode 100644 index 00000000..667d7983 --- /dev/null +++ b/backend/src/application/oauth.py @@ -0,0 +1,62 @@ +import json +from http import HTTPStatus + +from flask import Blueprint, jsonify, current_app, request +from google.auth.exceptions import GoogleAuthError +from google.auth.transport import requests +from google.oauth2 import id_token + +from src.application.config import GOOGLE_CLIENT_SECRET_PATH +from src.application.stub_views import anonymous_user_required +from src.database import wrapped_schemas +from src.database.db import db as core_db +from src.database.wrapped_models import User, Managers +from src.utils import generate_password + + +def load_google_config(): + with open(GOOGLE_CLIENT_SECRET_PATH) as f: + return json.load(f) + + +config_data = load_google_config() +oauth_blueprint = Blueprint('oauth', import_name=__name__, url_prefix="/oauth/", ) + + +@anonymous_user_required +@oauth_blueprint.route('/login/authorized', methods=['POST']) +def authorized(): + if "credential" not in request.json or not request.json["credential"]: + return jsonify({'error': 'Invalid request. No credentials.'}), HTTPStatus.BAD_REQUEST + + credential = request.json["credential"] + client_id = config_data['web']['client_id'] + try: + decoded_token = id_token.verify_oauth2_token(credential, + requests.Request(), client_id) + except GoogleAuthError as exc: + current_app.logger.error(f"Unable to decode token: {exc}") + return jsonify({'error': 'Token verification failed'}), HTTPStatus.BAD_REQUEST + + current_app.logger.debug(f"Successfully decoded token: {decoded_token}") + + email = decoded_token['email'] + name = decoded_token['name'] + + existing_user = User.query.filter_by(email=email).first() + + if existing_user: + current_app.logger.debug(f"Specified user with email {email} found") + if not existing_user.is_admin: + current_app.logger.error(f"Specified user with email {email} already exists and he/she is not an admin") + return jsonify({'error': 'This user already exists and he/she is not an admin.'}), HTTPStatus.UNAUTHORIZED + else: + password = generate_password() + current_app.logger.debug(f"Creation of a new user with email {email} started") + user = User.create_user(username=name, password=password, email=email, is_admin=True) + current_app.logger.debug(f"Creation of a new user with email {email} completed") + existing_user = user + + token = existing_user.encode_auth_token() + current_user = wrapped_schemas.UserSchema().dump(existing_user) + return jsonify({"token": token, "current_user": current_user}) diff --git a/backend/src/application/schemas.py b/backend/src/application/schemas.py index 770d6cdd..17486fe9 100644 --- a/backend/src/application/schemas.py +++ b/backend/src/application/schemas.py @@ -7,16 +7,16 @@ validate, validates_schema, ) +from werkzeug.exceptions import BadRequest + from src.application.enums import GenerateProposalStyle, ProposalStatus from src.application.marshmallow_ma import ma from src.database import models -from werkzeug.exceptions import BadRequest # pylint: disable=invalid-name VALIDATION_EXCEPTIONS = (BadRequest, TypeError, ValueError, ValidationError) - DATE_FORMAT = "%Y-%m-%d" @@ -140,3 +140,12 @@ class BulkSkillsSchema(Schema): class ChangeProposal2StatusRequestSchema(Schema): proposal_id = fields.Integer(allow_none=False, required=True) status = fields.Integer(required=True, validate=validate.OneOf(ProposalStatus.values())) + + +class InviteUserRequestSchema(Schema): + email = fields.Email(allow_none=False, required=True) + + +class RegistrationRequestSchema(Schema): + last_name = fields.String(required=False, allow_none=True) + first_name = fields.String(required=False, allow_none=True) diff --git a/backend/src/application/urls.py b/backend/src/application/urls.py index e3d014c5..fb625caf 100644 --- a/backend/src/application/urls.py +++ b/backend/src/application/urls.py @@ -55,3 +55,9 @@ "/freelancer_photo/.png", view_func=views.FreelancerPhoto().as_view("freelancer_photo"), ) +api_blueprint.add_url_rule( + "/invite/", view_func=views.InviteUser.as_view("invite_user") +) +public_blueprint.add_url_rule( + "/team_member_registration//", view_func=views.TeamMemberRegistrationPage.as_view("team_member_registration_page") +) diff --git a/backend/src/application/views.py b/backend/src/application/views.py index 40f8d698..f2f0a0b8 100644 --- a/backend/src/application/views.py +++ b/backend/src/application/views.py @@ -8,8 +8,9 @@ from json import JSONDecodeError import marshmallow -from flask import current_app, g, jsonify, request, send_file, render_template +from flask import current_app, g, jsonify, request, send_file, render_template, url_for from flask.views import MethodView +from flask_mail import Message from marshmallow import EXCLUDE, ValidationError from sqlalchemy import Integer, distinct, func, or_, text, desc, asc from sqlalchemy.orm import joinedload, aliased @@ -34,9 +35,10 @@ from src.database.generated_models import Manager, FreelancerSkill, Questions, \ FreelancersQuestionsAnswers, Candidate, CandidateSkill from src.database.generated_models import Skill, SkillCategory -from src.database.models import Application, Proposal, Proposal2StatusRel, ProposalQuestionAnswer +from src.database.models import Application, Proposal, Proposal2StatusRel, ProposalQuestionAnswer, RegistrationRequest from src.database.wrapped_models import Freelancer, User from src.database.wrapped_schemas import without_id +from src.utils import generate_password PrepareAppDataResult = namedtuple("PrepareAppDataResult", ["app", "errors"]) @@ -161,6 +163,129 @@ def post(self): return jsonify({"response": response}) +class InviteUser(MethodView): + decorators = [admin_user_required] + methods = ["POST"] + + def post(self): + try: + data = schemas.InviteUserRequestSchema().load(request.json) + current_app.logger.debug( + 'InviteUser view POST call' + ) + except schemas.VALIDATION_EXCEPTIONS as error: + raise exceptions.BadRequest(str(error)) + + email = data["email"] + + if User.query.filter(func.lower(User.email) == func.lower(email)).count(): + return jsonify({"message": "This user's email is already occupied, try another please."}), 500 + + self.send_invitation_email(email) + return {"message": f"Invitation email sent to {email}"}, 200 + + @staticmethod + def send_invitation_email(email): + registration_request = RegistrationRequest(email=email) + db.session.add(registration_request) + db.session.commit() + + uuid = registration_request.uuid + registration_link = url_for('public_blueprint.team_member_registration_page', uuid=uuid, _external=True) + + subject = "Invitation to Register" + body = f""" + Hello! + You have been invited to register on our website. + Follow the link to complete your registration: {registration_link} + """ + + message = Message(subject, recipients=[email], body=body) + + try: + mail = current_app.extensions['mail'] + current_app.logger.debug(f"Sending invitation email to {email}...") + mail.send(message) + current_app.logger.debug(f"Invitation email sent") + except Exception as exc: + current_app.logger.error(f"Error sending invitation email to {email}: {str(exc)}") + raise exceptions.BadRequest( + f"Unable to send email to {email}." + ) + else: + current_app.logger.debug("Email sent") + + return {"message": f"Invitation email sent to {email}"}, 200 + + +class TeamMemberRegistrationPage(MethodView): + methods = ["GET", "POST"] + + def get(self, uuid): + registration_request = RegistrationRequest.query.filter_by(uuid=uuid).first() + current_app.logger.info(f"Registration request: {registration_request}") + if not registration_request: + current_app.logger.error(f"Registration request not found: {uuid}") + return jsonify({"error": "Registration request not found!"}), 404 + + if not registration_request.active: + current_app.logger.error(f"Registration request has already been processed: {uuid}") + return jsonify({"error": "Registration request has already been processed!"}), 404 + + registration_link = url_for('public_blueprint.team_member_registration_page', uuid=uuid) + return render_template("team_member_registration.html", registration_link=registration_link) + + def post(self, uuid): + registration_request = RegistrationRequest.query.filter_by(uuid=uuid).first() + current_app.logger.info(f"Registration request: {registration_request}") + if not registration_request: + current_app.logger.error(f"Registration request not found: {uuid}") + return jsonify({"error": "Registration request not found!"}), 404 + + if not registration_request.active: + current_app.logger.error(f"Registration request has already been processed: {uuid}") + return jsonify({"error": "Registration request has already been processed!"}), 404 + + try: + data = schemas.RegistrationRequestSchema().load(request.json) + except schemas.VALIDATION_EXCEPTIONS as error: + current_app.logger.error(f"Error validating registration request: {str(error)}") + return jsonify({"error": str(error)}), 404 + + try: + user = User.create_user(email=registration_request.email, + username=registration_request.email, + password=generate_password(), + first_name=data['first_name'], + last_name=data['last_name']) + except exceptions.BadRequest as exc: + current_app.logger.error(f"Error creating user: {str(exc)}") + return jsonify({"error": exc.message}), 404 + + manager_code = uuid.replace("-", "")[:32] + + try: + manager = Manager(code=manager_code, name=f"{data['first_name']} {data['last_name']}") + db.session.add(manager) + # flushing the session ensures that the primary key of the manager is available for use in + # the user.manager_id field + db.session.flush() + + # Associate manager with user + user.manager_id = manager.id + db.session.add(user) + + registration_request.active = False + db.session.add(registration_request) + db.session.commit() + except Exception as exc: + current_app.logger.error(f"Error creating manager for user: {str(exc)}") + return jsonify({"error": str(exc)}), 500 + + current_app.logger.info(f"User {user.username} created successfully") + return jsonify({"message": "Registration successful!"}), 201 + + class GenerateProposal(MethodView): decorators = [authorized_user_required] methods = ["POST"] @@ -169,7 +294,7 @@ def post(self): try: data = schemas.GenerateProposalRequestSchema().load(request.json) current_app.logger.debug( - f'GenerateProposal view POST call' + 'GenerateProposal view POST call' ) except schemas.VALIDATION_EXCEPTIONS as error: raise exceptions.BadRequest(str(error)) diff --git a/backend/src/database/models.py b/backend/src/database/models.py index a0aae2a4..dc7ce845 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -1,8 +1,10 @@ from __future__ import annotations +import uuid from typing import Final from alembic_utils.pg_view import PGView +from sqlalchemy_utils import UUIDType from src.application.enums import ProposalStatus from src.database.db import db @@ -51,19 +53,19 @@ "job_id": lambda: db.Column(db.String(32), nullable=True, index=True), } -_PROPOSAL_FIELDS: Final = {k: v for k, v in _APPLICATION_FIELDS.items() if k not in {"status", "vendorUID", "vendorOrgUID", - "openingUID", "modifiedByUserUID", - "dashroomUID", "vendorSubOrgUID", - "applicationUID"}} | { - "connects_count": lambda: db.Column(db.Integer, nullable=True), - "pipeline_id": lambda: db.Column(db.Integer, nullable=True), - "time_in_ms": lambda: db.Column(db.Integer, nullable=True), - "job_id": lambda: db.Column(db.String(32), nullable=False, index=True) -} +_PROPOSAL_FIELDS: Final = {k: v for k, v in _APPLICATION_FIELDS.items() if + k not in {"status", "vendorUID", "vendorOrgUID", + "openingUID", "modifiedByUserUID", + "dashroomUID", "vendorSubOrgUID", + "applicationUID"}} | { + "connects_count": lambda: db.Column(db.Integer, nullable=True), + "pipeline_id": lambda: db.Column(db.Integer, nullable=True), + "time_in_ms": lambda: db.Column(db.Integer, nullable=True), + "job_id": lambda: db.Column(db.String(32), nullable=False, index=True) + } class Application(db.Model): - locals().update({k: v() for k, v in _APPLICATION_FIELDS.items()}) __tablename__ = "applications" @@ -155,6 +157,17 @@ class ReadByUser(db.Model): entity_table = db.Column(db.String(32), index=True) +class RegistrationRequest(db.Model): + __tablename__ = "registration_request" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + email = db.Column(db.String(64), nullable=False) + uuid = db.Column(UUIDType(binary=False), nullable=False, unique=True, default=uuid.uuid4) + active = db.Column(db.Boolean, nullable=False, default=True) + + def __str__(self): + return f"{self.email} - {self.uuid}" + + class PGViewModel(db.Model): __table_args__ = {"info": {"is_view": True}} __view_kind__ = PGView # May be PGMaterializedView too diff --git a/backend/src/database/wrapped_models.py b/backend/src/database/wrapped_models.py index 157a6b75..37daf292 100644 --- a/backend/src/database/wrapped_models.py +++ b/backend/src/database/wrapped_models.py @@ -5,8 +5,7 @@ import jwt from flask import url_for from passlib.context import CryptContext -from sqlalchemy import func, true - +from sqlalchemy import func from src.application import config, exceptions from src.database import generated_models from src.database.db import db @@ -32,7 +31,7 @@ def get_password_context(cls): return cls._pwcontext @classmethod - def create_user(cls, username: str, password: str, is_admin: bool = False): + def create_user(cls, username: str, password: str, is_admin: bool = False, **kwargs): # Проверяем, что нет пользователя с таким же username existing = User.query.filter( func.lower(User.username) == func.lower(username) @@ -46,6 +45,7 @@ def create_user(cls, username: str, password: str, is_admin: bool = False): username=username, password=cls.encode_password(password), is_admin=is_admin, + **kwargs, ) db.session.add(user) db.session.commit() @@ -98,3 +98,7 @@ def photo_url(self) -> str: if self.photo: return url_for("public_blueprint.freelancer_photo", freelancer_id=self.id) return None + + +class Managers(generated_models.Manager): + pass diff --git a/backend/src/migrations/versions/55585d78287e_add_registration_request_table.py b/backend/src/migrations/versions/55585d78287e_add_registration_request_table.py new file mode 100644 index 00000000..03d4fb5e --- /dev/null +++ b/backend/src/migrations/versions/55585d78287e_add_registration_request_table.py @@ -0,0 +1,35 @@ +"""add registration request table + +Revision ID: 55585d78287e +Revises: 3f24cf24019b +Create Date: 2023-12-21 18:04:23.563250 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils.types.uuid + +# revision identifiers, used by Alembic. +revision = '55585d78287e' +down_revision = '3f24cf24019b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('registration_request', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('email', sa.String(length=64), nullable=False), + sa.Column('uuid', sqlalchemy_utils.types.uuid.UUIDType(), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('registration_request') + # ### end Alembic commands ### diff --git a/backend/src/templates/team_member_registration.html b/backend/src/templates/team_member_registration.html new file mode 100644 index 00000000..4a42e4b0 --- /dev/null +++ b/backend/src/templates/team_member_registration.html @@ -0,0 +1,151 @@ + + + + + + User Registration Form + + + + +

User Registration

+ +
+ + + + + + + + +
+ + + +
+ + + + + diff --git a/backend/src/utils.py b/backend/src/utils.py index b9f37599..d4d39c2a 100644 --- a/backend/src/utils.py +++ b/backend/src/utils.py @@ -1,3 +1,5 @@ +import secrets +import string from enum import Enum from typing import Optional, Type, TypeVar @@ -28,3 +30,8 @@ def get_full_user_name(obj) -> Optional[str]: # Use username as last-resort fallback when actual data not available return result or getattr(obj, "username", None) or None + + +def generate_password(length=12) -> str: + characters = string.ascii_letters + string.digits + string.punctuation + return ''.join(secrets.choice(characters) for _ in range(length)) diff --git a/backend/tests/core/test_core.py b/backend/tests/core/test_core.py index 42da0e6c..f02d4ead 100644 --- a/backend/tests/core/test_core.py +++ b/backend/tests/core/test_core.py @@ -22,7 +22,7 @@ def test_token_expired_message(self, client, ordinary_user): token = jwt.encode(payload, config.SECRET_KEY, algorithm=config.JWT_ALGORITHM) time.sleep(3) # uri не имеет значения, любое. - response = utils.auth_call(client.get, "/api/apps/", token) + response = utils.auth_call(client.get, "/api/freelancers/", token) assert response.status_code == HTTPStatus.UNAUTHORIZED @pytest.mark.local diff --git a/client/.env.development b/client/.env.development new file mode 100644 index 00000000..83a40b18 --- /dev/null +++ b/client/.env.development @@ -0,0 +1 @@ +VUE_APP_GOOGLE_CLIENT_ID=266167116264-5kvgj0uelncfoogrmidgfckk2tc45ot2.apps.googleusercontent.com diff --git a/client/.env.production b/client/.env.production new file mode 100644 index 00000000..83a40b18 --- /dev/null +++ b/client/.env.production @@ -0,0 +1 @@ +VUE_APP_GOOGLE_CLIENT_ID=266167116264-5kvgj0uelncfoogrmidgfckk2tc45ot2.apps.googleusercontent.com diff --git a/client/Dockerfile.dev b/client/Dockerfile.dev index a4983e17..f3b1c77d 100644 --- a/client/Dockerfile.dev +++ b/client/Dockerfile.dev @@ -7,4 +7,4 @@ RUN npm install RUN npm install @vue/cli-service COPY . . -CMD ./node_modules/.bin/vue-cli-service serve --port 8080 +CMD ./node_modules/.bin/vue-cli-service serve --port 8080 --mode development diff --git a/client/public/index.html b/client/public/index.html index 41235286..60851c7e 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -6,6 +6,8 @@ <%= htmlWebpackPlugin.options.title %> + +