diff --git a/.gitignore b/.gitignore index 68bc17f..96e8b05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ __pycache__/ *.py[cod] *$py.class - # C extensions *.so @@ -127,6 +126,9 @@ venv/ ENV/ env.bak/ venv.bak/ +commit_message.txt +./commit_message.txt + # Spyder project settings .spyderproject diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d99f2f3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.formatting.provider": "none" +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2f5f4..3f02b29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,61 @@ -Contributing guidelines -======================= +# Contributing guidelines -### Git workflow +## Git workflow -* Use git-flow - create a feature branch from `develop`, e.g. `feature/new-feature` +* Use git-flow - create a feature branch from `develop`, e.g. `feat/new-feature` * Pull requests must contain a succinct, clear summary of what the user need is driving this feature change * Ensure your branch contains logical atomic commits before sending a pull request * You may rebase your branch after feedback if it's to include relevant updates from the develop branch. It is preferable to rebase here then a merge commit as a clean and straight history on develop with discrete merge commits for features is preferred * To find out more about contributing click [here](https://contributing.md/) + +## Commit messages + +Please use the following format for commit messages: + +```textbox +* fix: for a bug fix +* feat: for a new feature +* docs: for documentation changes +* style: for changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) +* refactor: for refactoring production code +* test: for adding tests +* chore: for updating build tasks, package manager configs, etc +* build: for changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) +``` + +## Pull requests + +* Pull requests should be made to the `develop` branch +* Pull requests should be made from a feature branch, not `develop` +* Pull requests should be made with a succinct, clear summary of what the user need is driving this feature change +* An automated template will be provided to help with this process, please fill it out as best you can + +## Release branches and Tagging + +* Once a single or set of changes has been made that can be released, we create a release branch off develop + * Releases should be as small as possible so that it is easier to fix issues and rollback code in production environment without losing many features at once + * Release branches should be of the following structure: + + ```sh + release/0.1.0 + ``` + + * The release name should abide by [semantic versioning (semver)](https://semver.org/) +* Once release branch has been created, a pull request should be made against the `main` branch +* Once approved, the release branch can be merged locally :warning: **WARNING - Do not push to remote** :warning: +* Tag the release and push to remote - [see tagging](#tagging) + +## Tagging + +* Tag latest commit on branch, this should be the local merge commit + * Check latest local commit, run: `git show HEAD` + * Create tag, run: `tag -s -m ` + * where `tag` is the semver value (should be taken from the release branch name) and `` is a general, short and concise message of the change +* Push release merge and tag to remote, run: `git push --follow-tags` +* Once pushed, go to the Github releases page for the repository in question, which can be found by clicking on the <> Code tab and clicking on Releases on the right hand side: + * Select Tags + * Click on the v0.1.0 tag that you just created + * Choose Create release from tag + * Copy the release name and make human-friendly (capitalise, remove /) to create a release title (e.g. release/0.1.0 -> Release 0.1.0) + * Add relevant release notes, this can be expanded from initial message created against the tag. Advised to use bullet points to list out changes + * Hit Publish release diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..38ec23c --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +.ONESHELL: + +.DEFAULT_GOAL := run +TOPICS := fix - feat - docs - style - refactor - test - chore - build + + +PYTHON = ./.venv/bin/python3 +PIP = ./.venv/bin/pip + + +.PHONY: run test clean check help commit + +venv/bin/activate: requirements.txt + python3 -m venv .venv + $(PIP) install -r requirements.txt + +venv: venv/bin/activate + . ./.venv/bin/activate + +run: venv + $(PYTHON) app.py + +test: venv + $(PYTHON) -m pytest + +commit: + @echo "Available topics:" + @echo "$(TOPICS)" + @read -p "Enter the topic for the commit: " topic; \ + read -p "Enter the commit message: " message; \ + echo "$${topic}: $${message}" > commit_message.txt; \ + git add .; \ + git commit -F commit_message.txt; \ + git push; \ + rm commit_message.txt + +check: venv + $(PIP) install safety + $(PIP) freeze | $(PYTHON) -m safety check --stdin + +clean: + @echo "Cleaning up..." + @find . -name "__pycache__" -type d -exec rm -rf {} + + @find . -name ".pytest_cache" -exec rm -rf {} + + @find . -name ".venv" -exec rm -rf {} + + +help: + @echo "gmake run - run the application" + @echo "gmake test - run the tests" + @echo "gmake clean - remove all generated files" + @echo "gmake check - check for security vulnerabilities" + @echo "gmake commit - commit changes to git" + @echo "gmake help - display this help" diff --git a/README.md b/README.md index 9087fc4..130c755 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,114 @@ # tdse-accessForce-bids-api -Bids API training project with Python and MongoDB +# API Documentation -# Bid Library +This API provides an endpoint to post a new bid document. -## Contents +## Prerequisites -- [Background](#background) - - [Before working on a bid](#before-working-on-a-bid) - - [Bid phases](#phases) -- [Brief](#brief) - - [Acceptance Criteria](#acceptance-criteria) - - [Iterations](#iterations) +- Python 3.x +- Flask +- Homebrew -## Background +## Running the API -Methods being a consultancy agency to win new work we make to make bids on client tenders. +1. Clone the repository to your local machine: -- A tender is a piece of work that an organisation (potential client) needs an external team to work on or to supplement an existing team -- A bid can comprise of several stages to win a tender, usually there are two phases which comprise of phase 1 and phase 2 + ```bash + git clone + ``` +2. Navigate to the root directory of the project: -### Before working on a bid + ```bash + cd tdse-accessForce-bids-api + ``` +3. Install python 3.x if not already installed. You can check if it is installed by running the following command: -Before phase 1, there is time for bidders like Methods to ask questions of the client tender. These questions are open to all those looking to bid on the tender and all questions and answers are available to all bidders on a single tender. + ```bash + python3 --version + ``` +4. Install Makefile if not already installed. You can check if it is installed by running the following command: -This step is a necessary is really important for Methods to understand whether we really want to bid on a particular tender. Some considerations before bidding: + ```bash + make --version + ``` +5. Version 3.81 or higher is required. If you do not have Make installed, you can install it with Homebrew: -- Do we like the project and hence want it? -- Is it good value? -- Will this get us known with a new client and give Methods leverage in future work? -- Is the tender something different that would expand our portfolio? -- Can we do it? + ```bash + brew install make + ``` +6. Run the following command to have all the commands to use the API with Makefile: -### Phases + ```bash + gmake help + ``` +7. Run the following command to start the API: -**Phase 1** compromises of a list of questions set by the tender; in government the scoring system for each question is out of 3, so if there are 6 questions a bidder can achieve a maximum score of 18. The answers to the questions usually have a word limit of 100 or 200 (in this phase). - -The client that puts out a tender decides the pass rate in phase 1 to progress to phase 2. The pass rate may not be known until results of all bids are completed for each of the bidders. The list below are common pass criteria you might come across on a bid (remember this can vary from client to client and even within multiple tenders by the same client): - -- All questions must have a score greater than 1, (so 2 minimum) -- A minimum overall score, e.g. 14 out of 18 -- The 3 highest bids assuming the bids met criteria 1 or/and 2 - -**Phase 2** is a lot more involved than phase 1, it can comprise of a face to face (or virtual) presentation alongside answers to questions limited to x number of words (limit set by client). These questions will cover team culture and technical solution. - -There are usually 3 categories that Methods are scored on and these are weighted by the client. The categories are: - -- Technical - questions from presentation or form and results from phase 1 -- Culture - questions in phase 2 -- Cost (a.k.a. Rate) - -The overall score is worked out as a percentage, (out of 100). Whichever bidder scores highest wins the bid and that is the end of the tendering process. - -Next steps: Statement of Work (SoW, the contract) is put together by the client, handing over CVs of potential staff Methods are going to supply and agreement on project start dates. + ```bash + gmake run + ``` +8. The API will be available at http://localhost:8080/api/bids -------------- -## Brief - -Currently Methods store all the information for tenders and bids in Sharepoint, the way the documents are stored and the information available can vary quite a lot making it hard for the bid team to find good answers to questions and successful bids for reuse. We currently do not store who helped answering questions against a bid and in some cases where Methods have done so it only informs us of their initials. - -What intend to build is an API that can store tender/bid information in a structured way to facilitate finding successful bids and high scoring questions. - -### Acceptance Criteria - -**Must** have: - -1. Ability to access all bid data, see list of data below: - - - tender title - - tender short description (problem statement) - - client - - date of the tender - - were Methods successful - - what phase did we get to - - how well we did in each of the phases - - any technologies or skills reuired by client tender - - tender questions and Methods answers and the respective scores - - who helped answer a question - - provide links to further information on bids stored in sharepoint - - when was the data last updated - - any skills or technologies listed in the answers to questions - -1. Ability to find any bid -1. Ability to add new bids -1. Ability to update a bid that is still in progress -1. Ability to delete a bid and associated -1. Ability to recover deleted bid data within 4 weeks of deletion -1. Ability to filter bids and questions based on success and score -1. Ability to sort bids and questions alphanumerically and page through the results -1. Ability to secure access to changing the data to certain users - -**Should** have: +## Accessing API Documentation (Swagger Specification) -1. Ability to search for bids containing particular text -1. Ability to search for questions containing particular text +1. Run the following command to start the API: -**Could** have: - -1. Ability to control different user access (permissions) based on roles - - - Admin - - Bid writers - - Bid viewers - -1. Ability to access this software anywhere in the UK - -**Would not** have: - -1. Due to size of some answers and the content not being soley text but images, diagrams etc. Methods does not wish to duplicate this information from Sharepoint into a filesystem like AWS S3 or Azure Storage - -### Iterations - -**Iteration 1** Build API and initial storage system to find, add, update and remove. Steps 1 to 8 from Must section - -**Iteration 2** Secure the API to users who need access, based on the "Principle least priviledge principle. Step 9 from Must section - -**Iteration 3** Build search engine to allow for a more sophisticated way of finding questions and bids related to your needs. Steps 1 and 2 from Should section - -**Iteration 4** Expand on access control to bid library based on roles, users and teams where necessary. Step 1 of Could section - -**Iteration 5** Host the bid library to be accessed by users across the country. Step 2 of Could section - -**Iteration 6** Build a web app to integrate with the bids API, create user journeys that allow users to find, add and update bid content - --------------- + ```bash + python app/app.py + ``` +2. The Swagger Specification will be available at http://localhost:8080/api/docs -**Note:** If this is part of your training, you should look for guidance by your mentor of how to progress this project. Your coach can use the [generic project rest API doc](/training/generic-projects/rest-api/README.md) to setup your initial project that will cover iteration 1. -------------- -Return to the [internal projects](https://github.com/methods/tdse-projects/blob/main/internal/README.md) for additional options and information. +## Installing and running an instance of MongoDB on your local machine (MacOS) + +### To install on Windows please see [here](https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-windows/) + +1. Install Homebrew if not already installed. You can check if it is installed by running the following command: + + ```bash + brew --version + ``` +2. Install MongoDB by running the following commands: + + ```bash + brew tap mongodb/brew + brew install mongodb-community + ``` +3. To run MongoDB (i.e. the mongod process) as a macOS service, run: + + ```bash + brew services start mongodb-community@6.0 + ``` +4. To verify that MongoDB is running, run: + + ```bash + brew services list + ``` + You should see the service `mongodb-community` listed as `started`. +5. Run the following command to stop the MongoDB instance, as needed: + + ```bash + brew services stop mongodb-community@6.0 + ``` +6. To begin using MongoDB, connect the MongoDB shell (mongosh) to the running instance. From a new terminal, issue the following: + + ```bash + mongosh + ``` +7. To create a new database called `bidsAPI`, run: + + ```bash + use bidsAPI + ``` +8. To exit the MongoDB shell, run the following command: + + ```bash + exit + ``` +OPTIONAL - Download MongoDB Compass to view the database in a GUI. You can download it from [here](https://www.mongodb.com/try/download/compass) -------------- diff --git a/api/controllers/__init__.py b/api/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/controllers/bid_controller.py b/api/controllers/bid_controller.py new file mode 100644 index 0000000..5db0ce7 --- /dev/null +++ b/api/controllers/bid_controller.py @@ -0,0 +1,87 @@ +from flask import Blueprint, jsonify, request +from datetime import datetime +from marshmallow import ValidationError +from api.models.status_enum import Status +from dbconfig.mongo_setup import dbConnection +from pymongo.errors import ConnectionFailure +from helpers.helpers import showInternalServerError, showNotFoundError, showValidationError, validate_and_create_bid_document, validate_bid_id_path, validate_bid_update + +bid = Blueprint('bid', __name__) + +@bid.route("/bids", methods=["GET"]) +def get_bids(): + # Get all bids from database collection + try: + db = dbConnection() + data = list(db['bids'].find({"status": {"$ne": Status.DELETED.value}})) + return {'total_count': len(data), 'items': data}, 200 + except Exception: + return showInternalServerError(), 500 + +@bid.route("/bids", methods=["POST"]) +def post_bid(): + try: + db = dbConnection() + # Process input and create data model + data = validate_and_create_bid_document(request.get_json()) + # Insert document into database collection + db['bids'].insert_one(data) + return data, 201 + # Return 400 response if input validation fails + except ValidationError as e: + return showValidationError(e), 400 + # Return 500 response in case of connection failure + except ConnectionFailure: + return showInternalServerError(), 500 + +@bid.route("/bids/", methods=["GET"]) +def get_bid_by_id(bid_id): + try: + bid_id = validate_bid_id_path(bid_id) + db = dbConnection() + data = db['bids'].find_one({"_id": bid_id , "status": {"$ne": Status.DELETED.value}}) + # Return 404 response if not found / returns None + if data is None: + return showNotFoundError(), 404 + return data, 200 + # Return 400 if bid_id is invalid + except ValidationError as e: + return showValidationError(e), 400 + # Return 500 response in case of connection failure + except ConnectionFailure: + return showInternalServerError(), 500 + +@bid.route("/bids/", methods=["PUT"]) +def update_bid_by_id(bid_id): + try: + bid_id = validate_bid_id_path(bid_id) + user_request = validate_bid_update(request.get_json()) + # Updates document where id is equal to bid_id + db = dbConnection() + data = db['bids'].find_one_and_update({"_id": bid_id}, {"$set": user_request}, return_document=True) + # Return 404 response if not found / returns None + if data is None: + return showNotFoundError(), 404 + return data, 200 + # Return 400 response if input validation fails + except ValidationError as e: + return showValidationError(e), 400 + # Return 500 response in case of connection failure + except ConnectionFailure: + return showInternalServerError(), 500 + +@bid.route("/bids/", methods=["DELETE"]) +def change_status_to_deleted(bid_id): + try: + bid_id = validate_bid_id_path(bid_id) + db = dbConnection() + data = db['bids'].find_one_and_update({"_id": bid_id, "status": {"$ne": Status.DELETED.value}}, {"$set": {"status": Status.DELETED.value, "last_updated": datetime.now().isoformat()}}) + if data is None: + return showNotFoundError(), 404 + return data, 204 + # Return 400 response if input validation fails + except ValidationError as e: + return showValidationError(e), 400 + # Return 500 response in case of connection failure + except ConnectionFailure: + return showInternalServerError(), 500 diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models/bid_model.py b/api/models/bid_model.py new file mode 100644 index 0000000..772b73c --- /dev/null +++ b/api/models/bid_model.py @@ -0,0 +1,21 @@ +from uuid import uuid4 +from datetime import datetime +from .links_model import LinksModel +from api.models.status_enum import Status + +# Description: Schema for the bid object +class BidModel(): + def __init__(self, tender, client, bid_date, alias=None, bid_folder_url=None, feedback=None, failed=None, was_successful=False, success=[], status=Status.IN_PROGRESS): + self._id = uuid4() + self.tender = tender + self.client = client + self.alias = alias + self.bid_date = bid_date + self.bid_folder_url = bid_folder_url + self.status = status # enum: "deleted", "in_progress" or "completed" + self.links = LinksModel(self._id) + self.was_successful = was_successful + self.success = success + self.failed = failed + self.feedback = feedback + self.last_updated = datetime.now() diff --git a/api/models/links_model.py b/api/models/links_model.py new file mode 100644 index 0000000..83f3827 --- /dev/null +++ b/api/models/links_model.py @@ -0,0 +1,5 @@ +# Schema for links object +class LinksModel(): + def __init__(self, id): + self.self = f"/bids/{id}" + self.questions = f"/bids/{id}/questions" diff --git a/api/models/status_enum.py b/api/models/status_enum.py new file mode 100644 index 0000000..592e02a --- /dev/null +++ b/api/models/status_enum.py @@ -0,0 +1,8 @@ +from enum import Enum, unique + +# Enum for status +@unique +class Status(Enum): + DELETED = "deleted" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" \ No newline at end of file diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/schemas/bid_request_schema.py b/api/schemas/bid_request_schema.py new file mode 100644 index 0000000..36bae3b --- /dev/null +++ b/api/schemas/bid_request_schema.py @@ -0,0 +1,53 @@ +from marshmallow import Schema, fields, post_load, validates, ValidationError +from api.models.bid_model import BidModel +from .links_schema import LinksSchema +from .phase_schema import PhaseSchema +from .feedback_schema import FeedbackSchema +from ..models.status_enum import Status + +# Marshmallow schema for request body +class BidRequestSchema(Schema): + tender = fields.Str(required=True, error_messages={"required": {"message": "Missing mandatory field"}}) + client = fields.Str(required=True, error_messages={"required": {"message": "Missing mandatory field"}}) + alias = fields.Str() + bid_date = fields.Date(format='%d-%m-%Y', required=True, error_messages={"required": {"message": "Missing mandatory field"}}) + bid_folder_url = fields.URL() + was_successful = fields.Boolean() + success = fields.List(fields.Nested(PhaseSchema)) + failed = fields.Nested(PhaseSchema) + feedback = fields.Nested(FeedbackSchema) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.context["failed_phase_values"] = set() + + @validates("success") + def validate_success(self, value): + phase_values = set() + for phase in value: + phase_value = phase.get("phase") + if phase_value in phase_values: + raise ValidationError("Phase value already exists in 'success' list and cannot be repeated.") + phase_values.add(phase_value) + + @validates("failed") + def validate_failed(self, value): + phase_value = value.get("phase") + if phase_value in self.context.get("success_phase_values", set()): + raise ValidationError("Phase value already exists in 'success' list and cannot be repeated.") + self.context["failed_phase_values"].add(phase_value) + + @validates("success") + def validate_success_and_failed(self, value): + success_phase_values = set() + failed_phase_values = self.context.get("failed_phase_values", set()) + for phase in value: + phase_value = phase.get("phase") + if phase_value in failed_phase_values: + raise ValidationError("Phase value already exists in 'failed' section and cannot be repeated.") + success_phase_values.add(phase_value) + + # Creates a Bid instance after processing + @post_load + def makeBid(self, data, **kwargs): + return BidModel(**data) \ No newline at end of file diff --git a/api/schemas/bid_schema.py b/api/schemas/bid_schema.py new file mode 100644 index 0000000..139a3b4 --- /dev/null +++ b/api/schemas/bid_schema.py @@ -0,0 +1,27 @@ +from marshmallow import Schema, fields, post_load +from datetime import datetime +from .links_schema import LinksSchema +from .phase_schema import PhaseSchema +from .feedback_schema import FeedbackSchema +from ..models.status_enum import Status + +# Marshmallow schema +class BidSchema(Schema): + _id = fields.UUID(required=True) + tender = fields.Str(required=True) + client = fields.Str(required=True) + alias = fields.Str() + bid_date = fields.Date(required=True) + bid_folder_url = fields.Str() + status = fields.Enum(Status, by_value=True, required=True) + links = fields.Nested(LinksSchema, required=True) + was_successful = fields.Bool(required=True) + success = fields.List(fields.Nested(PhaseSchema)) + failed = fields.Nested(PhaseSchema) + feedback = fields.Nested(FeedbackSchema) + last_updated = fields.DateTime(required=True) + + @post_load + def set_last_updated(self, data, **kwargs): + data["last_updated"] = datetime.now().isoformat() + return data \ No newline at end of file diff --git a/api/schemas/feedback_schema.py b/api/schemas/feedback_schema.py new file mode 100644 index 0000000..ec24452 --- /dev/null +++ b/api/schemas/feedback_schema.py @@ -0,0 +1,5 @@ +from marshmallow import Schema, fields + +class FeedbackSchema(Schema): + description = fields.Str(required=True) + url = fields.URL(required=True) \ No newline at end of file diff --git a/api/schemas/links_schema.py b/api/schemas/links_schema.py new file mode 100644 index 0000000..4a711e2 --- /dev/null +++ b/api/schemas/links_schema.py @@ -0,0 +1,5 @@ +from marshmallow import Schema, fields + +class LinksSchema(Schema): + self = fields.Str(required=True) + questions = fields.Str(required=True) \ No newline at end of file diff --git a/api/schemas/phase_schema.py b/api/schemas/phase_schema.py new file mode 100644 index 0000000..c723821 --- /dev/null +++ b/api/schemas/phase_schema.py @@ -0,0 +1,23 @@ +from marshmallow import Schema, fields, validates_schema, ValidationError +from enum import Enum, unique + +@unique +class Phase(Enum): + PHASE_1 = 1 + PHASE_2 = 2 + +class PhaseSchema(Schema): + phase = fields.Enum(Phase, required=True, by_value=True) + has_score = fields.Boolean(required=True) + score = fields.Integer() + out_of = fields.Integer() + + @validates_schema + def validate_score(self, data, **kwargs): + if data["has_score"] is True and "score" not in data: + raise ValidationError("Score is mandatory when has_score is set to true.") + + @validates_schema + def validate_out_of(self, data, **kwargs): + if data["has_score"] is True and "out_of" not in data: + raise ValidationError("Out_of is mandatory when has_score is set to true.") diff --git a/api/schemas/valid_bid_id_schema.py b/api/schemas/valid_bid_id_schema.py new file mode 100644 index 0000000..c91c83d --- /dev/null +++ b/api/schemas/valid_bid_id_schema.py @@ -0,0 +1,4 @@ +from marshmallow import Schema, fields, validate + +class valid_bid_id_schema(Schema): + bid_id = fields.Str(required=True, validate=validate.Length(min=36, error="Invalid bid Id")) \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..3ce7ba0 --- /dev/null +++ b/app.py @@ -0,0 +1,24 @@ +from flask import Flask +from flask_swagger_ui import get_swaggerui_blueprint + +from api.controllers.bid_controller import bid + +app = Flask(__name__) + +SWAGGER_URL = '/api/docs' # URL for exposing Swagger UI +API_URL = '/static/swagger_config.yml' # Our API url + +# Call factory function to create our blueprint +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, # Swagger UI static files will be mapped to '{SWAGGER_URL}/dist/' + API_URL, + config={ # Swagger UI config overrides + 'app_name': "Bids API Swagger" + }) + +app.register_blueprint(swaggerui_blueprint) +app.register_blueprint(bid, url_prefix='/api') + + +if __name__ == '__main__': + app.run(debug=True, port=8080) \ No newline at end of file diff --git a/dbconfig/__init__.py b/dbconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dbconfig/mongo_setup.py b/dbconfig/mongo_setup.py new file mode 100644 index 0000000..97a116d --- /dev/null +++ b/dbconfig/mongo_setup.py @@ -0,0 +1,10 @@ +from pymongo import MongoClient + +MONGO_URI = "mongodb://localhost:27017/bidsAPI" + +# Create a new client and connect to the server +def dbConnection(): + client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=10000) + db = client["bidsAPI"] + return db + \ No newline at end of file diff --git a/helpers/helpers.py b/helpers/helpers.py new file mode 100644 index 0000000..88a409c --- /dev/null +++ b/helpers/helpers.py @@ -0,0 +1,46 @@ +from flask import jsonify +import uuid +from datetime import datetime +from marshmallow import ValidationError +from api.schemas.bid_schema import BidSchema +from api.schemas.bid_request_schema import BidRequestSchema +from api.schemas.valid_bid_id_schema import valid_bid_id_schema + +def showInternalServerError(): + return jsonify({"Error": "Could not connect to database"}) + +def showNotFoundError(): + return jsonify({"Error": "Resource not found"}) + +def showValidationError(e): + return jsonify({"Error": str(e)}) + +def is_valid_uuid(string): + try: + uuid.UUID(str(string)) + return True + except ValueError: + return False + +def is_valid_isoformat(string): + try: + datetime.fromisoformat(string) + return True + except: + return False + +def validate_and_create_bid_document(request): + # Process input and create data model + bid_document = BidRequestSchema().load(request) + # Serialize to a JSON object + data = BidSchema().dump(bid_document) + return data + +def validate_bid_id_path(bid_id): + valid_bid_id = valid_bid_id_schema().load({"bid_id": bid_id}) + validated_id = valid_bid_id["bid_id"] + return validated_id + +def validate_bid_update(user_request): + data = BidSchema().load(user_request, partial=True) + return data \ No newline at end of file diff --git a/request_examples/all_fields.http b/request_examples/all_fields.http new file mode 100644 index 0000000..7f0a2e3 --- /dev/null +++ b/request_examples/all_fields.http @@ -0,0 +1,28 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json + +{ + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "23-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": true, + "out_of": 36, + "score": 30 + } + ], + "failed": { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } +} diff --git a/request_examples/delete.http b/request_examples/delete.http new file mode 100644 index 0000000..8814193 --- /dev/null +++ b/request_examples/delete.http @@ -0,0 +1 @@ +DELETE http://localhost:8080/api/bids/27fc4afb-fcdc-4bf7-9f8f-74f524831da4 HTTP/1.1 diff --git a/request_examples/get_all.http b/request_examples/get_all.http new file mode 100644 index 0000000..712676d --- /dev/null +++ b/request_examples/get_all.http @@ -0,0 +1 @@ +GET http://localhost:8080/api/bids HTTP/1.1 diff --git a/request_examples/invalid_int.http b/request_examples/invalid_int.http new file mode 100644 index 0000000..7c879a4 --- /dev/null +++ b/request_examples/invalid_int.http @@ -0,0 +1,28 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json + +{ + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": "ONE", + "has_score": true, + "score": 28, + "out_of": 36 + } + ], + "failed": { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } +} \ No newline at end of file diff --git a/request_examples/invalid_string.http b/request_examples/invalid_string.http new file mode 100644 index 0000000..12d71af --- /dev/null +++ b/request_examples/invalid_string.http @@ -0,0 +1,11 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json + +{ + "tender": 42, + "alias": "ONS", + "bid_date": "2023-12-25", + "bid_folder_url": "Not a valid URL", + "client": 7, + "was_successful": "String" +} \ No newline at end of file diff --git a/request_examples/invalid_url.http b/request_examples/invalid_url.http new file mode 100644 index 0000000..d806f3c --- /dev/null +++ b/request_examples/invalid_url.http @@ -0,0 +1,28 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json + +{ + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "not a URL", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": true, + "score": 28, + "out_of": 36 + } + ], + "failed": { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } +} \ No newline at end of file diff --git a/request_examples/missing_mandatory_field.http b/request_examples/missing_mandatory_field.http new file mode 100644 index 0000000..9d39d7a --- /dev/null +++ b/request_examples/missing_mandatory_field.http @@ -0,0 +1,27 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json + +{ + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": true, + "score": 28, + "out_of": 36 + } + ], + "failed": { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } +} \ No newline at end of file diff --git a/request_examples/missing_score_when_has_score_is_true.http b/request_examples/missing_score_when_has_score_is_true.http new file mode 100644 index 0000000..9e96a2a --- /dev/null +++ b/request_examples/missing_score_when_has_score_is_true.http @@ -0,0 +1,27 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json + +{ + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": true, + "out_of": 36 + } + ], + "failed": { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } +} \ No newline at end of file diff --git a/request_examples/update_bid.http b/request_examples/update_bid.http new file mode 100644 index 0000000..241514e --- /dev/null +++ b/request_examples/update_bid.http @@ -0,0 +1,6 @@ +PUT http://localhost:8080/api/bids/9f688442-b535-4683-ae1a-a64c1a3b8616 HTTP/1.1 +Content-Type: application/json + +{ + "tender": "UPDATED yeah" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d5e92d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +blinker +click +Flask +itsdangerous +Jinja2 +MarkupSafe +Werkzeug +pip == 23.2.0 +pytest +flask_swagger_ui +marshmallow +pymongo diff --git a/static/swagger_config.yml b/static/swagger_config.yml new file mode 100644 index 0000000..3ee9bcb --- /dev/null +++ b/static/swagger_config.yml @@ -0,0 +1,448 @@ +# --- +openapi: 3.0.3 +# -------------------------------------------- +# Info +info: + title: Bids API + description: | + This is a API for the Bids API. + You can find out more about + this API at [GitHub](https://github.com/methods/tdse-accessForce-bids-api). + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + Some useful links: + - [API repository](https://github.com/methods/tdse-accessForce-bids-api) + version: 1.0.0 + termsOfService: 'https://github.com/methods/tdse-accessForce-bids-api' + contact: + email: accessForce@example.com + license: + name: MIT License + url: 'https://github.com/methods/tdse-accessForce-bids-api/blob/develop/LICENSE.md' +# -------------------------------------------- +# Server +servers: + - url: 'http://localhost:8080/api/' + description: Local server +# -------------------------------------------- +# Tags +tags: + - name: bids + description: Everything about BIDS + externalDocs: + description: Find out more + url: example.com + - name: questions + description: Everything about QUESTIONS + externalDocs: + description: Find out more + url: example.com +# -------------------------------------------- +# Paths +paths: + /bids: +# -------------------------------------------- + get: + tags: + - bids + summary: Returns all bids + description: A JSON with item count and array of all bids + responses: + '200': # status code + description: Successful operation + content: + application/json: + schema: + type: object + properties: + total_count: + type: integer + example: 1 + items: + type: array + items: + $ref: '#/components/schemas/Bid' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + post: + tags: + - bids + summary: Create a new bid + description: Create a new bid + operationId: post_bid + requestBody: + $ref: '#/components/requestBodies/PostBid' + required: true + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + /bids/{bid_id}: +# -------------------------------------------- + get: + tags: + - bids + summary: Returns a single bid + description: Returns a single bid + operationId: get_bid + parameters: + - name: bid_id + in: path + description: ID of bid to return + required: true + schema: + type: string + format: uuid + responses: + '200': + description: A single bid + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + put: + tags: + - bids + summary: Update an existing bid + description: Update an existing bid by id + operationId: update_bid + parameters: + - name: bid_id + in: path + description: ID of bid to update + required: true + schema: + type: string + format: uuid + requestBody: + $ref: '#/components/requestBodies/UpdateBid' + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + delete: + tags: + - bids + summary: Soft delete a bid + description: Soft delete a bid + operationId: delete_bid + parameters: + - name: bid_id + in: path + description: ID of bid to delete + required: true + schema: + type: string + format: uuid + responses: + # return 204 (No Content) + '204': + description: Bid deleted + content: + noContent: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- +# Components +components: +# -------------------------------------------- +# Schemas + schemas: + Bid: + description: Bid document + type: object + required: + - _id + - tender + - client + - bid_date + - status + - links + - last_updated + properties: + tender: + type: string + example: 'Business Intelligence and Data Warehousing' + bid_folder_url: + type: string + example: 'https://organisation.sharepoint.com/Docs/dummyfolder' + last_updated: + type: string + example: "2023-06-27T14:05:17.623827" + failed: + type: object + $ref: '#/components/schemas/Phase' + example: '#/components/schemas/Phase' + feedback: + type: object + $ref: '#/components/schemas/Feedback' + success: + type: array + items: + type: object + $ref: '#/components/schemas/Phase' + alias: + type: string + example: 'ONS' + client: + type: string + example: 'Office for National Statistics' + links: + type: object + required: + - questions + - self + properties: + questions: + $ref: '#/components/schemas/QuestionsLink' + self: + $ref: '#/components/schemas/SelfLink' + _id: + type: string + format: uuid + example: "471fea1f-705c-4851-9a5b-df7bc2651428" + bid_date: + type: string + format: ISO-8601 + example: '2023-06-21' + status: + type: string + description: Bid Status + example: in_progress + enum: + - in_progress + - deleted + - completed + was_successful: + type: boolean + example: true +# -------------------------------------------- + Phase: + description: Phase information + type: object + required: + - phase + - has_score + properties: + phase: + description: Phase of bid + type: integer + enum: + - 1 + - 2 + example: 2 + score: + description: Score achieved at phase + type: integer + example: 22 + has_score: + description: Score information available or not + type: boolean + example: true + out_of: + description: Maximum score + type: integer + example: 36 +# -------------------------------------------- + Feedback: + description: Feedback from client (if provided) + type: object + required: + - url + - description + properties: + url: + description: Link to feedback + type: string + example: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: + description: Summary of feedback + type: string + example: 'Feedback from client in detail' +# -------------------------------------------- + QuestionsLink: + description: A link to a collection of questions for a bid + type: string + example: 'https://localhost:8080/api//bids/96d69775-29af-46b1-aaf4-bfbdb1543412/questions' +# -------------------------------------------- + SelfLink: + description: A link to the current resource + type: string + example: 'https://localhost:8080/api/bids/471fea1f-705c-4851-9a5b-df7bc2651428' +# -------------------------------------------- + BidRequestBody: + type: object + required: + - tender + - client + - bid_date + properties: + tender: + description: Name of tender + type: string + bid_folder_url: + description: Link to bid info + type: string + feedback: + description: Feedback info + type: object + $ref: '#/components/schemas/Feedback' + client: + description: Name of client tendering + type: string + alias: + description: Client alias + type: string + bid_date: + description: Date of bid + type: string + failed_phase: + description: Failed phase info + type: object + $ref: '#/components/schemas/Phase' + success_list: + description: List of successful phases + type: array + items: + $ref: '#/components/schemas/Phase' +# -------------------------------------------- +# Request bodies + requestBodies: + PostBid: + description: Bid object to be added to collection + content: + application/json: + schema: + $ref: '#/components/schemas/BidRequestBody' + examples: + 200 OK: + summary: 200 OK + value: + tender: 'Business Intelligence and Data Warehousing' + bid_folder_url: 'https://organisation.sharepoint.com/Docs/dummyfolder' + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + client: 'Office for National Statistics' + alias: 'ONS' + bid_date: '21-06-2023' + success: [ + { + "phase": 1, + "has_score": true, + "score": 28, + "out_of": 36 + } + ] + failed: { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } + was_successful: false + 400 Bad Request: + summary: 400 Bad Request + value: + bid_folder_url: 'https://organisation.sharepoint.com/Docs/dummyfolder' + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + client: 'Office for National Statistics' + alias: 'ONS' + bid_date: '21-06-2023' + UpdateBid: + description: Bid object to replace bid by Id + content: + application/json: + schema: + $ref: '#/components/schemas/BidRequestBody' + examples: + 200 OK: + summary: 200 OK + value: + tender: 'THIS HAS BEEN UPDATED' + bid_folder_url: 'https://organisation.sharepoint.com/Docs/dummyfolder' + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + client: 'Office for National Statistics' + alias: 'ONS' + bid_date: '21-06-2023' + success: [ + { + "phase": 1, + "has_score": true, + "score": 28, + "out_of": 36 + } + ] + failed: { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } + was_successful: false +# -------------------------------------------- +# Error responses + responses: + BadRequest: + description: Bad Request Error + content: + application/json: + schema: + type: object + example: { + "Error": "{'{field}': ['{message}']}" + } + NotFound: + description: Not Found Error + content: + application/json: + schema: + type: object + example: { + "Error": "Resource not found" + } + InternalServerError: + description: Internal Server Error + content: + application/json: + schema: + type: object + example: { + "Error": "Could not connect to database" + } +# -------------------------------------------- \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3c706e5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest +from flask import Flask +from api.controllers.bid_controller import bid + +@pytest.fixture +def client(): + app = Flask(__name__) + app.register_blueprint(bid, url_prefix='/api') + with app.test_client() as client: + yield client \ No newline at end of file diff --git a/tests/test_BidRequestSchema.py b/tests/test_BidRequestSchema.py new file mode 100644 index 0000000..ac01665 --- /dev/null +++ b/tests/test_BidRequestSchema.py @@ -0,0 +1,111 @@ +import pytest +from marshmallow import ValidationError +from api.schemas.bid_schema import BidSchema +from api.schemas.bid_request_schema import BidRequestSchema +from helpers.helpers import is_valid_uuid, is_valid_isoformat + +def test_bid_model(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": True, + "out_of": 36, + "score": 30 + } + ], + "failed": { + "phase": 2, + "has_score": True, + "score": 20, + "out_of": 36 + } + } + bid_document = BidRequestSchema().load(data) + to_post = BidSchema().dump(bid_document) + + id = to_post["_id"] + # Test that UUID is generated and is valid UUID + assert to_post["_id"] is not None + assert is_valid_uuid(id) is True + # Test UUID validator + assert is_valid_uuid("99999") is False + + # Test that links object is generated and URLs are correct + assert to_post["links"] is not None + assert "self" in to_post["links"] + assert to_post["links"]["self"] == f"/bids/{id}" + assert 'questions' in to_post["links"] + assert to_post["links"]["questions"] == f"/bids/{id}/questions" + + # Test that status is set to in_progress + assert to_post["status"] == "in_progress" + + # Test that last_updated field has been added and is valid + assert to_post["last_updated"] is not None + assert is_valid_isoformat(to_post["last_updated"]) is True + # Test ISOformat validator + assert is_valid_isoformat("07-06-2023") is False + +def test_validate_tender(): + data = { + "tender": 42, + "client": "Office for National Statistics", + "bid_date": "21-06-2023" + } + with pytest.raises(ValidationError): + BidRequestSchema().load(data) + +def test_validate_client(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": 42, + "bid_date": "21-06-2023" + } + with pytest.raises(ValidationError): + BidRequestSchema().load(data) + +def test_validate_bid_date(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-12-25" + } + with pytest.raises(ValidationError): + BidRequestSchema().load(data) + +def test_validate_bid_folder_url(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "bid_folder_url": "Not a valid URL" + } + + with pytest.raises(ValidationError): + BidRequestSchema().load(data) + +def test_validate_feedback(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": 42, + "url": "Invalid URL" + } + } + + with pytest.raises(ValidationError): + BidRequestSchema().load(data) \ No newline at end of file diff --git a/tests/test_delete_bid.py b/tests/test_delete_bid.py new file mode 100644 index 0000000..f31e993 --- /dev/null +++ b/tests/test_delete_bid.py @@ -0,0 +1,32 @@ +from pymongo.errors import ConnectionFailure +from unittest.mock import patch +from marshmallow import ValidationError + +# Case 1: Successful delete a bid by changing status to deleted +@patch('api.controllers.bid_controller.dbConnection') +def test_delete_bid_success(mock_dbConnection, client): + mock_db = mock_dbConnection.return_value + mock_db['bids'].find_one_and_update.return_value = { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "status": "deleted" + } + response = client.delete('/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9') + assert response.status_code == 204 + assert response.content_length is None + +# Case 2: Failed to call database +@patch('api.controllers.bid_controller.dbConnection') +def test_delete_bid_find_error(mock_dbConnection, client): + mock_db = mock_dbConnection.return_value + mock_db['bids'].find_one_and_update.side_effect = ConnectionFailure + response = client.delete('/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9') + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + +# Case 3: Validation error +@patch('api.controllers.bid_controller.dbConnection') +def test_get_bid_by_id_validation_error(mock_dbConnection, client): + mock_dbConnection.side_effect = ValidationError + response = client.delete('/api/bids/invalid_bid_id') + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'bid_id': ['Invalid bid Id']}"} \ No newline at end of file diff --git a/tests/test_get_bid_by_id.py b/tests/test_get_bid_by_id.py new file mode 100644 index 0000000..f979d02 --- /dev/null +++ b/tests/test_get_bid_by_id.py @@ -0,0 +1,50 @@ +from unittest.mock import patch +from pymongo.errors import ConnectionFailure +from marshmallow import ValidationError + +# Case 1: Successful get_bid_by_id +@patch('api.controllers.bid_controller.dbConnection') +def test_get_bid_by_id_success(mock_dbConnection, client): + mock_db = mock_dbConnection.return_value + mock_db['bids'].find_one.return_value = { + '_id': '1ff45b42-b72a-464c-bde9-9bead14a07b9', + 'tender': 'Business Intelligence and Data Warehousing' + } + + response = client.get('/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9') + + mock_dbConnection.assert_called_once() + mock_db['bids'].find_one.assert_called_once_with({'_id': '1ff45b42-b72a-464c-bde9-9bead14a07b9', 'status': {'$ne': 'deleted'}}) + assert response.status_code == 200 + assert response.get_json() == { + '_id': '1ff45b42-b72a-464c-bde9-9bead14a07b9', + 'tender': 'Business Intelligence and Data Warehousing' + } + +# Case 2: Connection error +@patch('api.controllers.bid_controller.dbConnection', side_effect=ConnectionFailure) +def test_get_bids_connection_error(mock_dbConnection, client): + response = client.get('/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9') + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + +# Case 3: Bid not found +@patch('api.controllers.bid_controller.dbConnection') +def test_get_bid_by_id_not_found(mock_dbConnection, client): + mock_db = mock_dbConnection.return_value + mock_db['bids'].find_one.return_value = None + + response = client.get('/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9') + + mock_dbConnection.assert_called_once() + mock_db['bids'].find_one.assert_called_once_with({'_id': '1ff45b42-b72a-464c-bde9-9bead14a07b9', 'status': {'$ne': 'deleted'}}) + assert response.status_code == 404 + assert response.get_json() == {"Error": "Resource not found"} + +# Case 4: Validation error +@patch('api.controllers.bid_controller.dbConnection') +def test_get_bid_by_id_validation_error(mock_dbConnection, client): + mock_dbConnection.side_effect = ValidationError + response = client.get('/api/bids/invalid_bid_id') + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'bid_id': ['Invalid bid Id']}"} diff --git a/tests/test_get_bids.py b/tests/test_get_bids.py new file mode 100644 index 0000000..10a99f1 --- /dev/null +++ b/tests/test_get_bids.py @@ -0,0 +1,19 @@ +from unittest.mock import patch +from pymongo.errors import ConnectionFailure + +# Case 1: Successful get +@patch('api.controllers.bid_controller.dbConnection') +def test_get_bids(mock_dbConnection, client): + mock_db = mock_dbConnection.return_value + mock_db['bids'].find.return_value = [] + + response = client.get('/api/bids') + assert response.status_code == 200 + assert response.get_json() == {'total_count': 0, 'items': []} + +# Case 2: Connection error +@patch('api.controllers.bid_controller.dbConnection', side_effect=Exception) +def test_get_bids_connection_error(mock_dbConnection, client): + response = client.get('/api/bids') + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} \ No newline at end of file diff --git a/tests/test_post_bid.py b/tests/test_post_bid.py new file mode 100644 index 0000000..be5aa6d --- /dev/null +++ b/tests/test_post_bid.py @@ -0,0 +1,188 @@ +from unittest.mock import patch +from pymongo.errors import ConnectionFailure + +# Case 1: Successful post +@patch('api.controllers.bid_controller.dbConnection') +def test_post_is_successful(mock_dbConnection, client): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": True, + "out_of": 36, + "score": 30 + } + ], + "failed": { + "phase": 2, + "has_score": True, + "score": 20, + "out_of": 36 + } + } + + # Mock the behavior of dbConnection + mock_db = mock_dbConnection.return_value + mock_db['bids'].insert_one.return_value = data + + response = client.post("api/bids", json=data) + assert response.status_code == 201 + assert "_id" in response.get_json() and response.get_json()["_id"] is not None + assert "tender" in response.get_json() and response.get_json()["tender"] == "Business Intelligence and Data Warehousing" + assert "client" in response.get_json() and response.get_json()["client"] == "Office for National Statistics" + assert "last_updated" in response.get_json() and response.get_json()["last_updated"] is not None + assert "bid_date" in response.get_json() and response.get_json()["bid_date"] == "2023-06-21" + + +# Case 2: Missing mandatory fields +def test_field_missing(client): + data = { + "client": "Sample Client", + "bid_date": "20-06-2023" + } + + response = client.post("api/bids", json=data) + assert response.status_code == 400 + assert response.get_json() == { + 'Error': "{'tender': {'message': 'Missing mandatory field'}}" + } + + +# Case 3: Connection error +@patch('api.controllers.bid_controller.dbConnection', side_effect=ConnectionFailure) +def test_get_bids_connection_error(mock_dbConnection, client): + # Mock the behavior of dbConnection + mock_db = mock_dbConnection.return_value + mock_db['bids'].insert_one.side_effect = Exception + response = client.get('/api/bids') + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 4: Neither success nor failed fields phase can be more than 2 +@patch('api.controllers.bid_controller.dbConnection') +def test_phase_greater_than_2(mock_dbConnection, client): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": True, + "out_of": 36, + "score": 30 + } + ], + "failed": { + "phase": 3, + "has_score": True, + "score": 20, + "out_of": 36 + } + } + + # Mock the behavior of dbConnection + mock_db = mock_dbConnection.return_value + mock_db['bids'].insert_one.side_effect = Exception + + response = client.post("api/bids", json=data) + assert response.status_code == 400 + assert response.get_json() == { + 'Error': "{'failed': {'phase': ['Must be one of: 1, 2.']}}" + } + + +# Case 5: Neither success nor failed fields can have the same phase +@patch('api.controllers.bid_controller.dbConnection') +def test_same_phase(mock_dbConnection, client): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": True, + "out_of": 36, + "score": 30 + } + ], + "failed": { + "phase": 1, + "has_score": True, + "score": 20, + "out_of": 36 + } + } + + # Mock the behavior of dbConnection + mock_db = mock_dbConnection.return_value + mock_db['bids'].insert_one.side_effect = Exception + + response = client.post("api/bids", json=data) + assert response.status_code == 400 + assert response.get_json() == { + 'Error': "{'success': [\"Phase value already exists in 'failed' section and cannot be repeated.\"]}" + } + + +# Case 6: Success cannot have the same phase in the list +@patch('api.controllers.bid_controller.dbConnection') +def test_success_same_phase(mock_dbConnection, client): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "21-06-2023", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "success": [ + { + "phase": 1, + "has_score": True, + "out_of": 36, + "score": 30 + }, + { + "phase": 1, + "has_score": True, + "out_of": 50, + "score": 60 + } + ], + } + + # Mock the behavior of dbConnection + mock_db = mock_dbConnection.return_value + mock_db['bids'].insert_one.side_effect = Exception + + response = client.post("api/bids", json=data) + assert response.status_code == 400 + assert response.get_json() == { + 'Error': "{'success': [\"Phase value already exists in 'success' list and cannot be repeated.\"]}" + } diff --git a/tests/test_update_bid_by_id.py b/tests/test_update_bid_by_id.py new file mode 100644 index 0000000..4fbb1cc --- /dev/null +++ b/tests/test_update_bid_by_id.py @@ -0,0 +1,35 @@ +from unittest.mock import patch + +# Case 1: Successful update +@patch('api.controllers.bid_controller.dbConnection') +def test_update_bid_by_id_success(mock_dbConnection, client): + mock_db = mock_dbConnection.return_value + mock_db['bids'].find_one_and_update.return_value = { + "_id": "9f688442-b535-4683-ae1a-a64c1a3b8616", + "tender": "Business Intelligence and Data Warehousing", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "was_successful": False + } + + bid_id = '9f688442-b535-4683-ae1a-a64c1a3b8616' + updated_bid = { + "tender": "UPDATED TENDER" + } + response = client.put(f"api/bids/{bid_id}", json=updated_bid) + mock_dbConnection.assert_called_once() + mock_db['bids'].find_one_and_update.assert_called_once() + assert response.status_code == 200 + +# Case 2: Invalid user input +@patch('api.controllers.bid_controller.dbConnection') +def test_input_validation(mock_dbConnection, client): + bid_id = '9f688442-b535-4683-ae1a-a64c1a3b8616' + updated_bid = { + "tender": 42 + } + response = client.put(f"api/bids/{bid_id}", json=updated_bid) + assert response.status_code == 400 + assert response.get_json()["Error"] == "{'tender': ['Not a valid string.']}" \ No newline at end of file