diff --git a/README.md b/README.md index eb87b21..0eb03ba 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,11 @@ Your answer to this test should be a repository. Please refrain from forking thi Feel free to implement this project in whatever way you feel like, we do not impose any limitations/requirements. You can choose any framework (or no framework), any design pattern (or none), any database (or none) for this. + +`` +## Getting Started + +* First start by creating a environment with the requirements from requirements.txt file. +* Then run the create-db script to create a initialize the database and populate it with some fake data. +* Set the FLASK_APP environment variable with the name of the app +* Run the flask app with the command `flask run` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..f7a6a7e --- /dev/null +++ b/app.py @@ -0,0 +1,18 @@ +from flask import redirect +import os +import config + +connexion_app = config.connexion_app + +connexion_app.add_api('api.yml') + +app = config.app + + +@app.route('/') +def doc_root(): + return redirect("/api/ui") + + +if __name__ == "__main__": + connexion_app.run(debug=True) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..ed0d0ce --- /dev/null +++ b/config.py @@ -0,0 +1,29 @@ +import connexion +import os +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine + +Session = sessionmaker() + +basedir = os.path.abspath(os.path.dirname(__file__)) +connexion_app = connexion.App(__name__, specification_dir='specification') + +app = connexion_app.app + +db_url = "sqlite:////" + os.path.join(basedir, 'vms.db') + +engine = create_engine(db_url) +Session.configure(bind=engine) +session = Session() + +# Configure the SQLAlchemy +app.config["SQLALCHEMY_ECHO"] = False +app.config["SQLALCHEMY_DATABASE_URI"] = db_url +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) + +ma = Marshmallow(app) + diff --git a/create-db.py b/create-db.py new file mode 100644 index 0000000..84c7d41 --- /dev/null +++ b/create-db.py @@ -0,0 +1,81 @@ +import datetime +import os +import random + +from config import db +from vms.model.employee import Employee +from vms.model.team import Team, team_employee +from vms.model.vacation import Vacation +from faker import Faker + + +fake = Faker() + +if os.path.exists('vms.db'): + os.remove('vms.db') + +db.create_all() + + +def add_team(): + for _ in range(3): + team = Team( + name=fake.color_name() + ) + db.session.add(team) + + +def add_employees(): + for _ in range(10): + customer = Employee( + first_name=fake.first_name(), + last_name=fake.last_name(), + address=fake.street_address(), + postcode=fake.postcode(), + email=fake.email(), + phone=fake.phone_number() + ) + db.session.add(customer) + + +def add_team_employee(): + teams = Team.query.all() + employees = Employee.query.all() + + for team in teams: + k = random.randint(1, 3) + employee = random.sample(employees, k) + team.employees.extend(employee) + + db.session.commit() + + +def add_vacations(): + employees = Employee.query.all() + for _ in range(5): + employee = random.choice(employees) + start_dates = [] + for i in range(1,10): + if len(start_dates) > 0: + start = start_dates.pop() + start_date = fake.date_between_dates(date_start=start+datetime.timedelta(days=1), date_end=start+datetime.timedelta(days=15)) + else: + start_date = fake.date_between(start_date='-1y', end_date='today') + end_date = fake.date_between_dates(date_start=start_date, date_end=start_date+datetime.timedelta(days=10)) + start_dates.append(end_date) + vacation_type = random.choices(['NORMALE', 'UNPAID', 'RTT'], [60, 5, 25])[0] + + vacation = Vacation( + employee_id=employee.id, + start_date=start_date, + end_date=end_date, + type=vacation_type + ) + db.session.add(vacation) + + +add_team() +add_employees() +add_team_employee() +add_vacations() +db.session.commit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bf2d6b6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +attrs==20.3.0 +certifi==2020.12.5 +chardet==3.0.4 +click==7.1.2 +clickclick==20.10.2 +connexion==2.7.0 +Faker==5.0.0 +Flask==1.1.2 +flask-marshmallow==0.14.0 +Flask-SQLAlchemy==2.4.4 +idna==2.10 +inflection==0.5.1 +install==1.3.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.2.0 +MarkupSafe==1.1.1 +marshmallow==3.9.1 +marshmallow-sqlalchemy==0.24.1 +openapi-spec-validator==0.2.9 +pyrsistent==0.17.3 +python-dateutil==2.8.1 +PyYAML==5.3.1 +requests==2.25.0 +six==1.15.0 +SQLAlchemy==1.3.20 +swagger-ui-bundle==0.0.8 +text-unidecode==1.3 +urllib3==1.26.2 +Werkzeug==1.0.1 diff --git a/specification/api.yml b/specification/api.yml new file mode 100644 index 0000000..1908dc6 --- /dev/null +++ b/specification/api.yml @@ -0,0 +1,382 @@ +openapi: 3.0.0 +info: + title: Vacation managing system api + description: An api to helpe managing employee's vacations + version: 1.0.0 +servers: + - url: /api + +paths: + + /employees: + parameters: + - name: first_name + description: first_name + in: query + schema: + type: string + - name: last_name + description: last_name + in: query + schema: + type: string + - name: address + description: address + in: query + schema: + type: string + - name: postcode + description: postcode + in: query + schema: + type: string + format: int64 + - name: email + description: email + in: query + schema: + type: string + format: email + - name: phone + description: phone + in: query + schema: + type: string + pattern: ^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$ + get: + tags: + - Employee + summary: Returns a list of employee. + description: Returns a list of employee. + operationId: employees_get_list + x-openapi-router-controller: vms.api.employee + + responses: + '200': # status code + description: A JSON array of employees + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Employee' + post: + tags: + - Employee + summary: Create employee. + description: Create employee. + operationId: employees_post + x-openapi-router-controller: vms.api.employee + responses: + '200': # status code + description: A JSON object of the created employee + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + /employee/{id}: + parameters: + - name: id + description: id + required: true + in: path + schema: + type: integer + get: + tags: + - Employee + summary: Get employee by id. + description: Get employee by id. + operationId: employee_get_by_id + x-openapi-router-controller: vms.api.employee + responses: + '200': # status code + description: A JSON object of the updated employee + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + patch: + tags: + - Employee + summary: update employee. + description: Update employee. + operationId: employee_patch + x-openapi-router-controller: vms.api.employee + parameters: + - name: first_name + description: first_name + in: query + schema: + type: string + - name: last_name + description: last_name + in: query + schema: + type: string + - name: address + description: address + in: query + schema: + type: string + - name: postcode + description: postcode + in: query + schema: + type: integer + format: int64 + - name: email + description: email + in: query + schema: + type: string + format: email + - name: phone + description: phone + in: query + schema: + type: string + pattern: ^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$ + responses: + '200': # status code + description: A JSON object of the updated employee + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + delete: + tags: + - Employee + summary: delete employee by id. + description: delete employee by id. + operationId: employee_delete + x-openapi-router-controller: vms.api.employee + responses: + '200': # status code + description: A JSON object of the updated employee + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + + /vacations: + get: + tags: + - Vacation + summary: Returns a list of vacation. + description: Returns a list of vacation. + operationId: vacations_get_list + x-openapi-router-controller: vms.api.vacation + parameters: + - name: start_date + description: start_date + in: query + schema: + type: string + format: date + example: 2020-01-01 + - name: end_date + description: end_date + in: query + schema: + type: string + format: date + example: 2020-12-31 + - name: type + description: type + in: query + schema: + type: string + enum: + - 'NORMAL' + - 'UNPAID' + - 'RTT' + - name: employee_id + description: employee_id + in: query + schema: + type: array + items: + type: integer + format: int64 + responses: + '200': # status code + description: A JSON array of vacations + content: + application/json: + schema: + type: object + properties: + count: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/Vacation' + post: + tags: + - Vacation + summary: Create vacation. + description: Create vacation. + operationId: vacations_post + x-openapi-router-controller: vms.api.vacation + parameters: + - name: start_date + description: start_date + in: query + required: true + schema: + type: string + format: date + example: 2020-01-01 + - name: end_date + description: end_date + in: query + required: true + schema: + type: string + format: date + example: 2020-12-31 + - name: type + description: type + in: query + required: true + schema: + type: string + enum: + - 'NORMAL' + - 'UNPAID' + - 'RTT' + - name: employee_id + description: employee_id + in: query + required: true + schema: + type: integer + format: int64 + responses: + '200': # status code + description: A JSON array of vacations + content: + application/json: + schema: + $ref: '#/components/schemas/Vacation' + '400': # Bad Request + description: Bad Request + /vacation/{id}: + parameters: + - name: id + description: id + required: true + in: path + schema: + type: integer + get: + tags: + - Vacation + summary: Get vacation by id. + description: Get vacation by id. + operationId: vacation_get_by_id + x-openapi-router-controller: vms.api.vacation + responses: + '200': # status code + description: Employee deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Vacation' + patch: + tags: + - Vacation + summary: update vacation. + description: Update vacation. + operationId: vacation_patch + x-openapi-router-controller: vms.api.vacation + parameters: + - name: start_date + description: start_date + in: query + required: true + schema: + type: string + format: date + example: 2020-01-01 + - name: end_date + description: end_date + in: query + required: true + schema: + type: string + format: date + example: 2020-01-02 + - name: type + description: type + in: query + required: true + schema: + type: string + enum: + - 'NORMAL' + - 'UNPAID' + - 'RTT' + - name: employee_id + description: employee_id + in: query + required: true + schema: + type: integer + format: int64 + responses: + '200': # status code + description: A JSON object of the updated vacation + content: + application/json: + schema: + $ref: '#/components/schemas/Vacation' + delete: + tags: + - Vacation + summary: delete vacation by id. + description: delete vacation by id. + operationId: vacation_delete + x-openapi-router-controller: vms.api.vacation + responses: + '200': # status code + description: Vacation deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Vacation' + +components: + schemas: + Employee: + type: object + properties: + id: + type: integer + first_name: + type: string + last_name: + type: string + address: + type: string + postcode: + type: string + email: + type: string + phone: + type: string + Vacation: + type: object + properties: + id: + type: integer + start_date: + type: string + end_date: + type: string + type: + type: string + employee_id: + type: string diff --git a/vms/__init__.py b/vms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vms/api/__init__.py b/vms/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vms/api/employee.py b/vms/api/employee.py new file mode 100644 index 0000000..cc8acff --- /dev/null +++ b/vms/api/employee.py @@ -0,0 +1,52 @@ +from config import db +from vms.core.session import get_all +from vms.model.employee import Employee, EmployeeSchema + +from vms.model.vacation import Vacation + + +def employees_get_list(**kwargs): + employees = get_all(Employee, kwargs) + employee_schema = EmployeeSchema(many=True) + data = employee_schema.dump(employees) + return data + + +def employee_get_by_id(id): + employee = Employee.query.get(id) + employee_schema = EmployeeSchema() + data = employee_schema.dump(employee) + return data + + +def employees_post(**kwargs): + del(kwargs['body']) + employee = Employee(**kwargs) + db.session.add(employee) + db.session.commit() + employee_schema = EmployeeSchema() + data = employee_schema.dump(employee) + return data + + +def employee_patch(id, **kwargs): + employee_updated = Employee.query.get(id) + del (kwargs['body']) + for att, value in kwargs.items(): + setattr(employee_updated, att, value) + db.session.commit() + employee_schema = EmployeeSchema() + data = employee_schema.dump(Employee.query.get(id)) + return data + + +def employee_delete(id): + employee = Employee.query.get(id) + employee_vacations_list = Vacation.query.filter_by(employee_id=id) + result = Employee.query.filter_by(id=id).delete() + if result: + employee_vacations_list.delete() + employee_schema = EmployeeSchema() + data = employee_schema.dump(employee) + db.session.commit() + return data diff --git a/vms/api/vacation.py b/vms/api/vacation.py new file mode 100644 index 0000000..d3fdfc1 --- /dev/null +++ b/vms/api/vacation.py @@ -0,0 +1,102 @@ +from werkzeug.exceptions import Forbidden, Conflict + +from config import db +from vms.core.helpers import format_date, verify_dates, \ + get_periods_intersection +from vms.model.vacation import Vacation, VacationSchema +from vms.core.session import get_all, get_specific_instance + + +def vacations_get_list(*args, **kwargs): + vacations = get_all(Vacation, kwargs, "start_date") + if "employee_id" in kwargs and len(kwargs['employee_id']) > 1: + vacations_intersect = [] + for i in range(len(vacations) - 1): + period = get_periods_intersection([[vacations[i].start_date, + vacations[i].end_date], + [vacations[i + 1].start_date, + vacations[i + 1].end_date]]) + if period and [i for i in period]: + vacations_intersect.append(period) + return {'count': len(vacations_intersect), + 'results': vacations_intersect} + vacation_schema = VacationSchema(many=True) + data = vacation_schema.dump(vacations) + response = {'count': len(data), 'results': data} + return response + + +def vacations_post(**kwargs): + del (kwargs['body']) + kwargs['start_date'] = format_date(kwargs['start_date']) + kwargs['end_date'] = format_date(kwargs['end_date']) + verify_dates([kwargs['start_date'], kwargs['end_date']]) + if get_specific_instance(Vacation, kwargs): + raise Conflict("Vacation already exist !") + vacation_post = Vacation() + vacations = get_all(Vacation, {"employee_id": [kwargs['employee_id']]}) + create_new_vacation = len(vacations) == 0 + for vacation in vacations: + if vacation.start_date > kwargs['end_date'] or \ + kwargs['start_date'] > vacation.end_date: + create_new_vacation = True + elif vacation.type == kwargs['type']: + create_new_vacation = False + start_date = min(vacation.start_date, kwargs['start_date']) + end_date = max(vacation.end_date, kwargs['end_date']) + if vacation.start_date == start_date and \ + vacation.end_date == end_date: + vacation_post = vacation + if vacation.start_date == start_date: + if vacation.end_date == end_date: + vacation_post = vacation + else: + vac = Vacation.query.get(vacation.id) + vac.end_date = kwargs['end_date'] + vacation_post = vac + elif vacation.end_date == end_date: + vac = Vacation.query.get(vacation.id) + vac.start_date = kwargs['start_date'] + vacation_post = vac + else: + create_new_vacation = True + break + else: + raise Forbidden("Two overlapped vacations must have the same type") + if create_new_vacation: + vacation_post = Vacation(**kwargs) + db.session.add(vacation_post) + db.session.commit() + employee_schema = VacationSchema() + data = employee_schema.dump(vacation_post) + return data + + +def vacation_get_by_id(id): + vacation = Vacation.query.get(id) + vacation_schema = VacationSchema() + data = vacation_schema.dump(vacation) + return data + + +def vacation_patch(id, **kwargs): + vacation_updated = Vacation.query.get(id) + del (kwargs['body']) + kwargs['start_date'] = format_date(kwargs['start_date']) + kwargs['end_date'] = format_date(kwargs['end_date']) + verify_dates([kwargs['start_date'], kwargs['end_date']]) + for att, value in kwargs.items(): + setattr(vacation_updated, att, value) + db.session.commit() + vacation_schema = VacationSchema() + data = vacation_schema.dump(Vacation.query.get(id)) + return data + + +def vacation_delete(id): + vacation = Vacation.query.get(id) + Vacation.query.filter_by(id=id).delete() + vacation_schema = VacationSchema() + data = vacation_schema.dump(vacation) + db.session.commit() + return data diff --git a/vms/core/__init__.py b/vms/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vms/core/helpers.py b/vms/core/helpers.py new file mode 100644 index 0000000..e3d446b --- /dev/null +++ b/vms/core/helpers.py @@ -0,0 +1,24 @@ +import dateutil.parser +from werkzeug.exceptions import BadRequest + + +def format_date(date): + return dateutil.parser.isoparse(date).date() + + +def verify_dates(dates): + if dates[0].weekday() > 4: + raise BadRequest("The start date must be not in the weekend") + if dates[1].weekday() > 4: + raise BadRequest("The end date must be not in the weekend") + if dates[0] > dates[1]: + raise BadRequest("The start date must be earlier than end date") + + +def get_periods_intersection(period): + if period[1][0] > period[0][1] or period[0][0] > period[1][1]: + return None + else: + maximum = max(period[0][0], period[1][0]) + minimum = min(period[0][1], period[1][1]) + return [maximum, minimum] if maximum != minimum else [maximum] diff --git a/vms/core/session.py b/vms/core/session.py new file mode 100644 index 0000000..7dc94b6 --- /dev/null +++ b/vms/core/session.py @@ -0,0 +1,28 @@ +from config import db +from vms.core.helpers import format_date + + +def get_all(model, filter, order_by=None): + query = db.session.query(model) + for attr, value in filter.items(): + if attr == "start_date": + query = query.filter(getattr(model, attr) >= format_date(value)) + elif attr == "end_date": + query = query.filter(getattr(model, attr) <= format_date(value)) + elif attr == "employee_id": + query = query.filter(getattr(model, attr).in_(value)) + else: + query = query.filter(getattr(model, attr) == value) + if order_by: + results = query.order_by(getattr(model, order_by)).all() + else: + results = query.all() + return results + + +def get_specific_instance(model, filter): + query = db.session.query(model) + for attr, value in filter.items(): + query = query.filter(getattr(model, attr) == value) + results = query.all() + return results diff --git a/vms/model/__init__.py b/vms/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vms/model/employee.py b/vms/model/employee.py new file mode 100644 index 0000000..5711de8 --- /dev/null +++ b/vms/model/employee.py @@ -0,0 +1,35 @@ +from config import db, ma +from vms.model.vacation import Vacation +from marshmallow import fields + + +class Employee(db.Model): + + id = db.Column(db.Integer, primary_key=True) + first_name = db.Column(db.String(50), nullable=False) + last_name = db.Column(db.String(50), nullable=False) + address = db.Column(db.String(500), nullable=False) + postcode = db.Column(db.String(50), nullable=False) + email = db.Column(db.String(50), nullable=False, unique=True) + phone = db.Column(db.String(50), nullable=False, unique=True) + + vacations = db.relationship('Vacation', backref='employee', + cascade='all, delete, delete-orphan', + passive_deletes=True) + + +class EmployeeSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = Employee + sqla_session = db.session + + vacations = fields.Nested("EmployeeVacationSchema", default=[], many=True) + + +class EmployeeVacationSchema(ma.SQLAlchemyAutoSchema): + + id = fields.Int() + start_date = fields.Date() + end_date = fields.Date() + type = fields.Str() + employee_id = fields.Int() diff --git a/vms/model/team.py b/vms/model/team.py new file mode 100644 index 0000000..7128c31 --- /dev/null +++ b/vms/model/team.py @@ -0,0 +1,16 @@ +from config import db + +team_employee = db.Table('team_employee', + db.Column('team_id', db.Integer, + db.ForeignKey('team.id'), primary_key=True), + db.Column('employee_id', db.Integer, + db.ForeignKey('employee.id'), + primary_key=True)) + + +class Team(db.Model): + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False) + + employees = db.relationship('Employee', secondary=team_employee) diff --git a/vms/model/vacation.py b/vms/model/vacation.py new file mode 100644 index 0000000..3d3715e --- /dev/null +++ b/vms/model/vacation.py @@ -0,0 +1,31 @@ +from config import db, ma +from marshmallow import fields + + +class Vacation(db.Model): + + id = db.Column(db.Integer, primary_key=True) + start_date = db.Column(db.Date, nullable=False) + end_date = db.Column(db.Date, nullable=False) + type = db.Column(db.String(50), nullable=False) + employee_id = db.Column(db.Integer, db.ForeignKey('employee.id', + ondelete='CASCADE')) + + +class VacationSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = Vacation + sqla_session = db.session + + employee = fields.Nested("VacationEmployeeSchema", default=[]) + + +class VacationEmployeeSchema(ma.SQLAlchemyAutoSchema): + + id = fields.Int() + first_name = fields.Str() + last_name = fields.Str() + address = fields.Str() + postcode = fields.Str() + email = fields.Str() + phone = fields.Str()