diff --git a/.env b/.env index 56cc4c13..8ec94291 100644 --- a/.env +++ b/.env @@ -7,3 +7,8 @@ REPO_ORG=https://github.com/canonical SQLALCHEMY_DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres TASK_DELAY=30 DIRECTORY_API_TOKEN=token +JIRA_EMAIL=email@example.com +JIRA_TOKEN=token +JIRA_URL=https://warthogs.atlassian.net +JIRA_LABELS=sites_BAU +JIRA_COPY_UPDATES_EPIC=KAN-1 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e5b7564b..b3ac3ac0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -159,6 +159,11 @@ jobs: -e GH_TOKEN=token \ -e REPO_ORG=https://github.com/canonical \ -e SQLALCHEMY_DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres \ + -e JIRA_EMAIL=example@canonical.com \ + -e JIRA_TOKEN=jiratoken \ + -e JIRA_URL=https://example.atlassian.net \ + -e JIRA_LABELS=somelabel \ + -e JIRA_COPY_UPDATES_EPIC=WD-9999999 \ --network host \ websites-content-system & sleep 1 curl --head --fail --retry-delay 1 --retry 30 --retry-connrefused http://localhost diff --git a/README.md b/README.md index e59a9ec8..9ddb4b91 100644 --- a/README.md +++ b/README.md @@ -120,4 +120,20 @@ $ docker inspect | grep IPAddress "name": "/", "title": null } +} +``` + +#### Making a webpage update request + +
+ POST /request-changes +
+ +```json +{ + "due_date": "2022-01-01", + "reporter_id": 1, + "webpage_id": 31, + "type": 1, + "description": "This is a description", } \ No newline at end of file diff --git a/konf/site.yaml b/konf/site.yaml index abdbfd64..c8644386 100644 --- a/konf/site.yaml +++ b/konf/site.yaml @@ -28,6 +28,25 @@ env: key: token name: directory-api + - name: JIRA_EMAIL + secretKeyRef: + key: jira-email + name: cms-jira + + - name: JIRA_TOKEN + secretKeyRef: + key: jira-token + name: cms-jira + + - name: JIRA_URL + value: "https://warthogs.atlassian.net" + + - name: JIRA_LABELS + value: "sites_BAU" + + - name: JIRA_COPY_UPDATES_EPIC + value: "WD-12643" + # Overrides for production production: replicas: 1 diff --git a/migrations/versions/7b0c727c0201_.py b/migrations/versions/7b0c727c0201_.py new file mode 100644 index 00000000..9df1d5ff --- /dev/null +++ b/migrations/versions/7b0c727c0201_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 7b0c727c0201 +Revises: c4073a9759fc +Create Date: 2024-09-19 10:41:48.215628 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7b0c727c0201' +down_revision = 'c4073a9759fc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jira_tasks', schema=None) as batch_op: + batch_op.alter_column('jira_id', + existing_type=sa.INTEGER(), + type_=sa.String(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jira_tasks', schema=None) as batch_op: + batch_op.alter_column('jira_id', + existing_type=sa.String(), + type_=sa.INTEGER(), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index b2adb6db..8b560e25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ multiprocess==0.70.16 psycopg2-binary==2.9.9 PyYAML==6.0.2 Flask-Migrate==4.0.7 +Flask-Pydantic==0.12.0 Flask-SQLAlchemy==3.1.1 talisker[gunicorn,gevent,flask,prometheus,raven]==0.21.3 valkey==6.0.0b1 diff --git a/webapp/__init__.py b/webapp/__init__.py index b1e7bc2a..420b84bd 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -2,6 +2,7 @@ from webapp.cache import init_cache from webapp.context import base_context +from webapp.jira import init_jira from webapp.models import init_db from webapp.sso import init_sso from webapp.tasks import init_tasks @@ -26,4 +27,7 @@ def create_app(): # Initialize tasks init_tasks(app) + # Initialize JIRA + init_jira(app) + return app diff --git a/webapp/app.py b/webapp/app.py index 6c7c91ef..47ee01c5 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -1,15 +1,18 @@ from os import environ +import requests from flask import jsonify, render_template, request +from flask_pydantic import validate from webapp import create_app +from webapp.helper import create_jira_task, get_or_create_user_id +from webapp.models import Reviewer, Webpage, db, get_or_create +from webapp.schemas import ( + ChangesRequestModel, +) from webapp.site_repository import SiteRepository from webapp.sso import login_required from webapp.tasks import LOCKS -from webapp.models import get_or_create, db, Reviewer, Webpage -from webapp.helper import get_or_create_user_id - -import requests app = create_app() @@ -43,7 +46,7 @@ def index(path): return render_template("index.html") -@app.route('/get-users/', methods=['GET']) +@app.route("/get-users/", methods=["GET"]) @login_required def get_users(username: str): query = """ @@ -59,27 +62,28 @@ def get_users(username: str): } """ - headers = { - "Authorization": "token " + environ.get("DIRECTORY_API_TOKEN") - } + headers = {"Authorization": "token " + environ.get("DIRECTORY_API_TOKEN")} # Currently directory-api only supports strict comparison of field values, # so we have to send two requests instead of one for first and last names response = requests.post( - "https://directory.wpe.internal/graphql/", json={ - 'query': query, - 'variables': {'name': username.strip()}, - }, headers=headers, verify=False) - - if (response.status_code == 200): - users = response.json().get('data', {}).get( - 'employees', []) + "https://directory.wpe.internal/graphql/", + json={ + "query": query, + "variables": {"name": username.strip()}, + }, + headers=headers, + verify=False, + ) + + if response.status_code == 200: + users = response.json().get("data", {}).get("employees", []) return jsonify(list(users)) else: return jsonify({"error": "Failed to fetch users"}), 500 -@app.route('/set-reviewers', methods=['POST']) +@app.route("/set-reviewers", methods=["POST"]) @login_required def set_reviewers(): data = request.get_json() @@ -99,15 +103,14 @@ def set_reviewers(): # Create new reviewer rows for user_id in user_ids: - get_or_create(db.session, - Reviewer, - user_id=user_id, - webpage_id=webpage_id) + get_or_create( + db.session, Reviewer, user_id=user_id, webpage_id=webpage_id + ) return jsonify({"message": "Successfully set reviewers"}), 200 -@app.route('/set-owner', methods=['POST']) +@app.route("/set-owner", methods=["POST"]) @login_required def set_owner(): data = request.get_json() @@ -123,3 +126,17 @@ def set_owner(): db.session.commit() return jsonify({"message": "Successfully set owner"}), 200 + + +@app.route("/request-changes", methods=["POST"]) +@login_required +@validate() +def request_changes(body: ChangesRequestModel): + + # Make a request to JIRA to create a task + try: + create_jira_task(app, body.model_dump()) + except Exception as e: + return jsonify(str(e)), 500 + + return jsonify("Task created successfully"), 201 diff --git a/webapp/helper.py b/webapp/helper.py index 6d14cb79..5abddce9 100644 --- a/webapp/helper.py +++ b/webapp/helper.py @@ -1,4 +1,4 @@ -from webapp.models import get_or_create, db, User +from webapp.models import JiraTask, User, db, get_or_create def get_or_create_user_id(user): @@ -6,13 +6,40 @@ def get_or_create_user_id(user): user_hrc_id = user.get("id") user_exists = User.query.filter_by(hrc_id=user_hrc_id).first() if not user_exists: - user_exists, _ = get_or_create(db.session, - User, - name=user.get("name"), - email=user.get("email"), - team=user.get("team"), - department=user.get("department"), - job_title=user.get("jobTitle"), - hrc_id=user_hrc_id) + user_exists, _ = get_or_create( + db.session, + User, + name=user.get("name"), + email=user.get("email"), + team=user.get("team"), + department=user.get("department"), + job_title=user.get("jobTitle"), + hrc_id=user_hrc_id, + ) return user_exists.id + + +def create_jira_task(app, task): + """ + Create a new issue on jira and add a record to the db + """ + # TODO: If an epic already exists for this request, add subtasks to it. + + jira = app.config["JIRA"] + issue = jira.create_issue( + due_date=task["due_date"], + reporter_id=task["reporter_id"], + webpage_id=task["webpage_id"], + request_type=task["type"], + description=task["description"], + ) + + # Create jira task in the database + get_or_create( + db.session, + JiraTask, + jira_id=issue["key"], + webpage_id=task["webpage_id"], + user_id=task["reporter_id"], + ) diff --git a/webapp/jira.py b/webapp/jira.py new file mode 100644 index 00000000..3d8475bb --- /dev/null +++ b/webapp/jira.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import requests +from requests.auth import HTTPBasicAuth + +from webapp.models import User, Webpage, db + + +class Jira: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + EPIC = "10000" + SUBTASK = "10013" + COPY_UPDATE = 0 + PAGE_REFRESH = 1 + NEW_WEBPAGE = 2 + + def __init__( + self, + url: str, + email: str, + token: str, + labels: str, + copy_updates_epic: str, + ): + """ + Initialize the Jira object. + + Args: + url (str): The URL of the Jira instance. + email (str): The email address of the user. + token (str): The API token of the user. + labels (str): The labels to be applied to the created issues. + copy_updates_epic (str): The key of the epic to copy updates to. + """ + self.url = url + self.auth = HTTPBasicAuth(email, token) + self.labels = labels + self.copy_updates_epic = copy_updates_epic + + def __request__( + self, method: str, url: str, data: dict = {}, params: dict = {} + ): + if data: + data = json.dumps(data) + response = requests.request( + method, + url, + data=data, + headers=self.headers, + auth=self.auth, + params=params, + ) + + if response.status_code == 200 or response.status_code == 201: + return response.json() + + raise Exception( + "Failed to make a request to Jira. Status code:" + f" {response.status_code}. Response: {response.text}" + ) + + def get_reporter_jira_id(self, user_id): + """ + Get the Jira ID of the user who reported the issue. + + Args: + user_id (int): The ID of the user who reported the issue. + + Returns: + str: The Jira ID of the user who reported the issue. + """ + # Try to get the user from the database + user = User.query.filter_by(id=user_id).first() + if not user: + raise ValueError(f"User with ID {user_id} not found") + # If the user already has a Jira account ID, return it + if user.jira_account_id: + return user.jira_account_id + # Otherwise get it from jira + jira_user = self.find_user(user.email) + if not jira_user: + raise ValueError(f"User with email {user.email} not found in Jira") + # Update the user in the database + user.jira_account_id = jira_user[0]["accountId"] + db.session.commit() + return jira_user[0]["accountId"] + + def find_user(self, query: str): + """ + Find a user based on a query. + + Args: + query (str): The query string to search for a user. + + Returns: + dict: A dictionary containing the user information. + """ + return self.__request__( + method="GET", + url=f"{self.url}/rest/api/3/user/search", + params={"query": query}, + ) + + def create_task( + self, + summary: str, + issue_type: int, + description: str, + parent: str, + reporter_jira_id: str, + due_date: datetime, + ): + """ + Creates a task or subtask in Jira. + + Args: + summary (str): The summary of the task. + issue_type (int): The ID of the issue type for the task. + description (str): The description of the task. + parent (str): The key of the parent issue. If None, the task will + be created without a parent. + reporter_jira_id (str): The ID of the reporter of the task. + due_date (datetime): The due date of the task. + + Returns: + dict: The response from the Jira API containing information about + the created task. + """ + if parent: + parent = {"key": parent} + + payload = { + "fields": { + "description": { + "content": [ + { + "content": [ + { + "text": description, + "type": "text", + } + ], + "type": "paragraph", + } + ], + "type": "doc", + "version": 1, + }, + "summary": summary, + "issuetype": {"id": issue_type}, + "labels": self.labels, + "reporter": {"id": reporter_jira_id}, + "duedate": due_date, + "parent": parent, + "project": {"id": "10492"}, # Web and Design-ENG + "components": [ + {"id": "12655"}, # Sites Tribe + ], + }, + "update": {}, + } + return self.__request__( + method="POST", url=f"{self.url}/rest/api/3/issue", data=payload + ) + + def create_issue( + self, + request_type: int, + description: str, + reporter_id: str, + webpage_id: int, + due_date: datetime, + ): + """Creates a new issue in Jira. + + Args: + request_type (int): The type of the request. 0 for Epic, 1 or 2 for + Task. + description (str): The description of the issue. + reporter_id (str): The ID of the reporter. + webpage_id (int): The ID of the webpage. + due_date (datetime): The due date of the issue. + + Returns: + dict: The response from the Jira API. + """ + # Get the webpage + webpage = Webpage.query.filter_by(id=webpage_id).first() + if not webpage: + raise Exception(f"Webpage with ID {webpage_id} not found") + + # Get the reporter ID + reporter_jira_id = self.get_reporter_jira_id(reporter_id) + + # Determine summary message + if request_type == self.COPY_UPDATE: + summary = f"Copy update {webpage.name}" + elif request_type == self.PAGE_REFRESH: + summary = f"Page refresh for {webpage.name}" + elif request_type == self.NEW_WEBPAGE: + summary = f"New webpage for {webpage.name}" + + # Create the issue depending on the request type + if ( + request_type == self.NEW_WEBPAGE + or request_type == self.PAGE_REFRESH + ): + parent = None + # Create epic + epic = self.create_task( + summary=summary, + issue_type=self.EPIC, + description=description, + parent=parent, + reporter_jira_id=reporter_jira_id, + due_date=due_date, + ) + + # Create subtasks for this epic + for subtask_name in ["UX", "Visual", "Dev"]: + self.create_task( + summary=f"{subtask_name} - {summary}", + issue_type=self.SUBTASK, + description=description, + parent=epic["key"], + reporter_jira_id=reporter_jira_id, + due_date=due_date, + ) + return epic + + elif request_type == self.COPY_UPDATE: + parent = self.copy_updates_epic + + return self.create_task( + summary=summary, + issue_type=self.SUBTASK, + description=description, + parent=parent, + reporter_jira_id=reporter_jira_id, + due_date=due_date, + ) + + +def init_jira(app): + app.config["JIRA"] = Jira( + url=app.config["JIRA_URL"], + email=app.config["JIRA_EMAIL"], + token=app.config["JIRA_TOKEN"], + labels=app.config["JIRA_LABELS"].split(","), + copy_updates_epic=app.config["JIRA_COPY_UPDATES_EPIC"], + ) diff --git a/webapp/models.py b/webapp/models.py index ed66c951..e44ab5f6 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -113,16 +113,23 @@ class Reviewer(db.Model, DateTimeMixin): user = relationship("User", back_populates="reviewers") webpages = relationship("Webpage", back_populates="reviewers") +class JIRATaskStatus: + TRIAGED = "TRIAGED" + UNTRIAGED = "UNTRIAGED" + BLOCKED = "BLOCKED" + IN_PROGRESS = "IN_PROGRESS" + TO_BE_DEPLOYED = "TO_BE_DEPLOYED" + DONE = "DONE" + REJECTED = "REJECTED" class JiraTask(db.Model, DateTimeMixin): __tablename__ = "jira_tasks" id: int = Column(Integer, primary_key=True) - jira_id: int = Column(Integer) + jira_id: str = Column(String) webpage_id: int = Column(Integer, ForeignKey("webpages.id")) user_id: int = Column(Integer, ForeignKey("users.id")) - status: str = Column(String) # Will be filled from Jira API - created_at: str = Column(String) + status: str = Column(String, default=JIRATaskStatus.TRIAGED) webpages = relationship("Webpage", back_populates="jira_tasks") user = relationship("User", back_populates="jira_tasks") diff --git a/webapp/schemas.py b/webapp/schemas.py new file mode 100644 index 00000000..f5cadad0 --- /dev/null +++ b/webapp/schemas.py @@ -0,0 +1,26 @@ +from functools import wraps + +from pydantic import BaseModel + + +def validate_input(model): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Create a pydantic basemodel instance with provided kwargs + # Throws a ValidationError if the input data is invalid + model(**kwargs) + # Call the original function with validated inputs + return func(*args, **kwargs) + + return wrapper + + return decorator + + +class ChangesRequestModel(BaseModel): + due_date: str + reporter_id: int + webpage_id: int + type: int + description: str diff --git a/webapp/settings.py b/webapp/settings.py index 225ff2d4..61b6562f 100644 --- a/webapp/settings.py +++ b/webapp/settings.py @@ -10,3 +10,8 @@ SQLALCHEMY_DATABASE_URI = environ.get( "SQLALCHEMY_DATABASE_URI", "sqlite:///project.db" ) +JIRA_EMAIL = environ.get("JIRA_EMAIL") +JIRA_TOKEN = environ.get("JIRA_TOKEN") +JIRA_URL = environ.get("JIRA_URL") +JIRA_LABELS = environ.get("JIRA_LABELS") +JIRA_COPY_UPDATES_EPIC = environ.get("JIRA_COPY_UPDATES_EPIC") \ No newline at end of file