diff --git a/.github/workflows/add-issues-to-board.yaml b/.github/workflows/add-issues-to-board.yaml new file mode 100644 index 0000000..88e5a69 --- /dev/null +++ b/.github/workflows/add-issues-to-board.yaml @@ -0,0 +1,15 @@ +name: add-issues-to-board + +on: + issues: + types: [opened, reopened, transferred] + +jobs: + add-issues-to-board: + runs-on: ubuntu-latest + steps: + - name: Add to Board + uses: actions/add-to-project@v0.5.0 + with: + project-url: https://github.com/orgs/octue/projects/22 + github-token: ${{ secrets.PROJECT_ISSUES_TOKEN }} diff --git a/.github/workflows/gcloud-deploy.yml b/.github/workflows/gcloud-deploy.yml index 7d9a790..0506ec4 100644 --- a/.github/workflows/gcloud-deploy.yml +++ b/.github/workflows/gcloud-deploy.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - id: checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - id: deploy-contact uses: google-github-actions/deploy-cloud-functions@main @@ -19,8 +19,8 @@ jobs: runtime: python38 region: europe-west1 credentials: ${{ secrets.GCP_SERVICE_ACCOUNT }} - env_vars: HUBSPOT_API_KEY=${{ secrets.HUBSPOT_API_KEY }},ACCESS_CONTROL_ALLOW_ORIGIN=${{ secrets.ACCESS_CONTROL_ALLOW_ORIGIN }},SUPPORT_PIPELINE_TICKET_OWNER_ID=${{ secrets.SUPPORT_PIPELINE_TICKET_OWNER_ID }} - source_dir: gcloud/functions/hubspot + env_vars: MAILGUN_API_KEY=${{ secrets.MAILGUN_API_KEY }},MAILCHIMP_API_KEY=${{ secrets.MAILCHIMP_API_KEY }} + source_dir: gcloud/functions - id: deploy-subscribe uses: google-github-actions/deploy-cloud-functions@main @@ -29,5 +29,5 @@ jobs: runtime: python38 region: europe-west1 credentials: ${{ secrets.GCP_SERVICE_ACCOUNT }} - env_vars: HUBSPOT_API_KEY=${{ secrets.HUBSPOT_API_KEY }},ACCESS_CONTROL_ALLOW_ORIGIN=${{ secrets.ACCESS_CONTROL_ALLOW_ORIGIN }} - source_dir: gcloud/functions/hubspot + env_vars: MAILGUN_API_KEY=${{ secrets.MAILGUN_API_KEY }},MAILCHIMP_API_KEY=${{ secrets.MAILCHIMP_API_KEY }} + source_dir: gcloud/functions diff --git a/.github/workflows/semantic.yml b/.github/workflows/semantic.yml index cf5e9aa..e346251 100644 --- a/.github/workflows/semantic.yml +++ b/.github/workflows/semantic.yml @@ -1,22 +1,21 @@ name: semantic -on: [push] +on: + pull_request: + branches: + - main jobs: check-semantic-version: + if: "!contains(github.event.head_commit.message, 'skipci')" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: - # Set fetch-depth to 0 to fetch all tags (necessary for git-mkver to determine the correct semantic version). fetch-depth: 0 - - uses: actions/setup-python@v2 - - name: Install git-mkver - run: | - curl -L https://github.com/idc101/git-mkver/releases/download/v1.2.1/git-mkver-linux-amd64-1.2.1.tar.gz \ - | tar xvz \ - && sudo mv git-mkver /usr/local/bin - - name: Install semantic version checker - run: pip install git+https://github.com/octue/conventional-commits + - name: Check version - run: check-semantic-version package.json + uses: octue/check-semantic-version@1.0.0.beta-9 + with: + path: package.json + breaking_change_indicated_by: major diff --git a/.github/workflows/synced-add-issues-to-octue-board.yaml b/.github/workflows/synced-add-issues-to-octue-board.yaml deleted file mode 100644 index c6fbf5f..0000000 --- a/.github/workflows/synced-add-issues-to-octue-board.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# WARNING: This file is synced from the octue/.github repository. -# Do not edit this file in any repo other than octue/.github, or your changes will be overwritten - -name: synced-add-issues-to-octue-board - -on: - issues: - types: [opened, reopened] - -jobs: - add-issues-to-octue-board: - runs-on: ubuntu-latest - steps: - - name: Add to Board - uses: monry/actions-add-issue-to-project@v1 - with: - # Personal Access Token with `repo`, `org:read` and `org:write` granted - github-token: ${{ secrets.PROJECT_AUTOMATION_GITHUB_TOKEN }} - project-owner: "octue" - project-number: 22 - issue-id: ${{ github.event.issue.node_id }} diff --git a/.github/workflows/update-pr.yml b/.github/workflows/update-pr.yml deleted file mode 100644 index 0902e80..0000000 --- a/.github/workflows/update-pr.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: update-pr - -on: [pull_request] - -jobs: - description: - if: "!contains(github.event.head_commit.message, 'skipci')" - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 - with: - # Set fetch-depth to 0 to fetch all commit history (necessary for compiling pull request description). - fetch-depth: 0 - - - name: Install release note compiler - run: pip install git+https://github.com/octue/conventional-commits - - - name: Compile new pull request description - run: | - echo 'PULL_REQUEST_NOTES<> $GITHUB_ENV - echo "$(compile-release-notes PULL_REQUEST_START --pull-request-url=${{ github.event.pull_request.url }} --api-token=${{ secrets.GITHUB_TOKEN }})" >> $GITHUB_ENV - echo EOF >> $GITHUB_ENV - - - name: Update pull request body - uses: riskledger/update-pr-description@v2 - with: - body: ${{ env.PULL_REQUEST_NOTES }} - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-pull-request.yml b/.github/workflows/update-pull-request.yml new file mode 100644 index 0000000..22b2cd9 --- /dev/null +++ b/.github/workflows/update-pull-request.yml @@ -0,0 +1,34 @@ +# This workflow updates the pull request description with an auto-generated section containing the categorised commit +# message headers of the commits since the last pull request merged into main. The auto generated section is enveloped +# between two comments: "" and "". Anything +# outside these in the description is left untouched. Auto-generated updates can be skipped for a commit if +# "" is added to the pull request description. + +name: update-pull-request + +# Only trigger for pull requests into main branch. +on: + pull_request: + branches: + - main + +jobs: + description: + if: "!contains(github.event.pull_request.body, '')" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Generate PR description + uses: octue/generate-pull-request-description@1.0.0.beta-2 + id: pr-description + with: + pull_request_url: ${{ github.event.pull_request.url }} + api_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update pull request body + uses: riskledger/update-pr-description@v2 + with: + body: ${{ steps.pr-description.outputs.pull_request_description }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 784c8fd..b6c7506 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ yarn-error.log # Storybook build files build-storybook.log storybook-static + + +# IDEs +.idea/ +.vscode/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3cafc0d..e4d267e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,16 +19,13 @@ repos: hooks: - id: check-branch-name args: + # Main branch... - '^main$' - - '^dev$' - - '^staging$' - - '^devops/([a-z][a-z0-9]*)(-[a-z0-9]+)*$' - - '^doc/([a-z][a-z0-9]*)(-[a-z0-9]+)*$' - - '^feature/([a-z][a-z0-9]*)(-[a-z0-9]+)*$' - - '^fix/([a-z][a-z0-9]*)(-[a-z0-9]+)*$' - - '^hotfix/([a-z][a-z0-9]*)(-[a-z0-9]+)*$' - - '^refactor/([a-z][a-z0-9]*)(-[a-z0-9]+)*$' - - '^release/(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' + # Or a descriptive kebab-case branch name + # which, if it includes an issue number, will + # auto-link pull requests to that issue on github, eg: + # my-branch-to-fix-issue-#6 + - '^([a-z][a-z0-9#/]*)(-[a-z0-9#]+)*$' - repo: https://github.com/pre-commit/mirrors-prettier rev: 'v2.2.1' @@ -36,7 +33,7 @@ repos: - id: prettier - repo: https://github.com/octue/pre-commit-hooks - rev: 0.4.0 + rev: 0.9.0 hooks: - id: check-commit-message-is-conventional stages: [commit-msg] diff --git a/gcloud/functions/hubspot/cors.py b/gcloud/functions/cors.py similarity index 100% rename from gcloud/functions/hubspot/cors.py rename to gcloud/functions/cors.py diff --git a/gcloud/functions/hubspot/errors.py b/gcloud/functions/errors.py similarity index 100% rename from gcloud/functions/hubspot/errors.py rename to gcloud/functions/errors.py diff --git a/gcloud/functions/hubspot/forms.py b/gcloud/functions/forms.py similarity index 100% rename from gcloud/functions/hubspot/forms.py rename to gcloud/functions/forms.py diff --git a/gcloud/functions/hubspot/hubspot.py b/gcloud/functions/hubspot/hubspot.py deleted file mode 100644 index fc1a71a..0000000 --- a/gcloud/functions/hubspot/hubspot.py +++ /dev/null @@ -1,132 +0,0 @@ -from os import environ -import logging -from datetime import datetime -from hubspot3 import Hubspot3 -from hubspot3.crm_associations import Definitions as AssociationDefinitions - - -logger = logging.getLogger(__name__) - - -HUBSPOT_API_KEY = environ.get("HUBSPOT_API_KEY", None) -if HUBSPOT_API_KEY is None: - raise ValueError( - "Attempted to start the HubSpot client but no api key given. Generate an api key over at hubspot, then add it to the function as an environment variable (config var on heroku)." - ) - - -# TODO Subscribing to a paid hubspot package allows us to automate ticket assignation, which is what should be done -# in the future, but for now I don't want to pay £66/month just to receive an email notification (by default, nobody -# gets notified when a ticket is created unless it's assigned). -# TODO Add a drop down that would allow us to assign different tickets to different pipelines -# To display ticket owner ids while setting this up, use the print_owner_ids() function below -TICKET_OWNER_IDS = {"Support Pipeline": environ.get("SUPPORT_PIPELINE_TICKET_OWNER_ID")} - -client = Hubspot3(api_key=HUBSPOT_API_KEY) - - -def print_owner_ids(): - """ Lists owner ids by email address so you can configure the ticket owner ids above. - - Note: We could indicate pipeline owners by email address. But we'd then have to make this call as part of the form - submission, so it would add an extra round trip to the hubspot API during each submission. - """ - print(dict((owner["email"], owner["ownerId"]) for owner in client.owners.get_owners())) - - -def initialise(): - """ Initialise key variables used to create tickets and manage contacts in hubspot - :return: (support_pipeline, support_stage, subscription_statuses) - """ - - support_pipeline = None - support_stage = None - subscription_statuses = None - - try: - # List of available email subscription types from hubspot - subscription_types = client.email_subscription.get_subscription_types() - subscription_statuses = [ - {"id": definition["id"], "subscribed": True, "optState": "OPT_IN"} - for definition in subscription_types["subscriptionDefinitions"] - ] - - # Get the support pipeline to add 'contact' tickets to - ticket_pipelines = client.crm_pipelines.get_all(object_type="tickets") - support_pipeline = None - for pipeline in ticket_pipelines: - if pipeline["label"] == "Support Pipeline": - support_pipeline = pipeline["pipelineId"] - # Sigh. Stages aren't ordered by display order. - for stage in pipeline["stages"]: - if stage["displayOrder"] == 0: - support_stage = stage["stageId"] - - if support_pipeline is None: - raise Exception( - 'Cannot get "Support Pipeline" from hubspot. Check your support ticket pipelines and create this one if necessary.' - ) - - except Exception as e: - - logger.error( - "Error initialising connection to HubSpot CRM. Perhaps you have no internet connection? Error was %s", - getattr(e, "message", "unknown"), - ) - - raise - - return support_pipeline, support_stage, subscription_statuses - - -support_pipeline, support_stage, subscription_statuses = initialise() - - -def create_or_update_contact(email, first_name=None, last_name=None): - """ Get a contact from hubspot, updating their name. If the contact don't exist, create them. - """ - properties = [{"property": "email", "value": email}] - if first_name is not None: - properties.append({"property": "firstname", "value": first_name}) - if last_name is not None: - properties.append({"property": "lastname", "value": last_name}) - contact = client.contacts.create_or_update_by_email(email, data={"properties": properties}) - - return contact - - -def subscribe_contact(email): - """ Subscribe a contact to all of the email subscription types - :param email: - :return: - """ - client.email_subscription.update_subscriptions( - email, - subscriptions=subscription_statuses, - portalSubscriptionLegalBasis="LEGITIMATE_INTEREST_CLIENT", - portal_legal_basis_explanation="Clicked subscribe on octue.com on {}".format(datetime.utcnow()), - ) - - -def create_ticket(message, subject, contact): - """ Create a support ticket associated to a contact - :param message: - :param subject: - :param contact: - :return: - """ - - ticket_owner_id = TICKET_OWNER_IDS["Support Pipeline"] - - ticket = client.tickets.create( - pipeline=support_pipeline, - stage=support_stage, - properties={"subject": subject, "content": message, "hubspot_owner_id": ticket_owner_id}, - ) - - if contact is not None: - client.crm_associations.create( - from_object=ticket["objectId"], - to_object=contact["vid"], - definition=AssociationDefinitions.TICKET_TO_CONTACT, - ) diff --git a/gcloud/functions/hubspot/requirements.txt b/gcloud/functions/hubspot/requirements.txt deleted file mode 100644 index dc83700..0000000 --- a/gcloud/functions/hubspot/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -flask_wtf -wtforms[email] -hubspot3 diff --git a/gcloud/functions/mailchimp.py b/gcloud/functions/mailchimp.py new file mode 100644 index 0000000..532be3b --- /dev/null +++ b/gcloud/functions/mailchimp.py @@ -0,0 +1,47 @@ +import requests +from os import environ +import hashlib + + +# Get list ids: +# resp = requests.get( +# f"{MAILCHIMP_BASE_URL}/lists", auth=("apikey", MAILCHIMP_API_KEY) +# ) +# resp.raise_for_status() +# print(resp.json()['lists'][0]['id']) +MAILCHIMP_LIST_IDS = { + "Octue Newsletter": "cf71ac7a6b", +} +MAILCHIMP_LIST_ID = MAILCHIMP_LIST_IDS["Octue Newsletter"] + +MAILCHIMP_API_KEY = environ.get("MAILCHIMP_API_KEY", None) +MAILCHIMP_SERVER_DOMAIN = environ.get("MAILCHIMP_SERVER_DOMAIN", "us21") +MAILCHIMP_BASE_URL = f"https://{MAILCHIMP_SERVER_DOMAIN}.api.mailchimp.com/3.0" + +if MAILCHIMP_API_KEY is None: + raise ValueError("The MAILCHIMP_API_KEY must be set.") + + +def add_or_update_subscriber(email): + """Add a subscriber to the main mailchimp list, o if they're already present, update their status to 'subscribed'""" + + endpoint = ( + f"{MAILCHIMP_BASE_URL}/lists/{MAILCHIMP_LIST_ID}/members/{_email_hash(email)}" + ) + data = { + "email_address": email, + "status_if_new": "subscribed", + "status": "subscribed", + } + headers = { + "Authorization": f"apikey {MAILCHIMP_API_KEY}", + "Content-Type": "application/json", + } + + response = requests.put(endpoint, json=data, headers=headers, timeout=5) + response.raise_for_status() + + +def _email_hash(email): + """Generate an MD5 hash of an email address, which is required by the Mailchimp API for identifying the member in the list.""" + return hashlib.md5(email.encode("utf-8")).hexdigest() diff --git a/gcloud/functions/mailgun.py b/gcloud/functions/mailgun.py new file mode 100644 index 0000000..697e3dd --- /dev/null +++ b/gcloud/functions/mailgun.py @@ -0,0 +1,31 @@ +from os import environ +import requests + +RECIPIENTS_LIST = ["support@octue.com"] + +MAILGUN_API_KEY = environ.get("MAILGUN_API_KEY", None) +MAILGUN_SENDER_DOMAIN = environ.get("MAILGUN_SENDER_DOMAIN", "mg.octue.com") +MAILGUN_API_URL = environ.get("MAILGUN_API_URL", "https://api.mailgun.net/v3") + +if MAILGUN_API_KEY is None: + raise ValueError("MAILGUN_API_KEY must be set.") + + +def forward_contact_email(firstName, lastName, email, message): + """Forward a contact form submission to the Octue support team, using mailgun""" + return requests.post( + f"{MAILGUN_API_URL}/{MAILGUN_SENDER_DOMAIN}/messages", + auth=("api", MAILGUN_API_KEY), + timeout=5, + data={ + "from": "Octue Website (planex-site) ", + "to": RECIPIENTS_LIST, + "subject": "Contact Form Submission", + "text": f""" +Message from: {firstName} {lastName} ({email}) +Message Contents: +----------------- +{message} +""", + }, + ) diff --git a/gcloud/functions/hubspot/main.py b/gcloud/functions/main.py similarity index 57% rename from gcloud/functions/hubspot/main.py rename to gcloud/functions/main.py index 573b267..d358d75 100644 --- a/gcloud/functions/hubspot/main.py +++ b/gcloud/functions/main.py @@ -1,8 +1,11 @@ import logging -from forms import ContactForm, SubscribeForm -from hubspot import create_ticket, create_or_update_contact, subscribe_contact +import functions_framework +from mailgun import forward_contact_email + from cors import cors_enabled from errors import clean_errors +from forms import ContactForm, SubscribeForm +from mailchimp import add_or_update_subscriber logger = logging.getLogger(__name__) @@ -10,9 +13,10 @@ INVALID_METHOD_RESPONSE = {"nonFieldErrors": "Method Not Allowed. Try 'POST'."} +@functions_framework.http @cors_enabled def contact(request): - """Creates a 'contact' type support request in HubSpot + """Forwards a message to an octue email address Args: request (flask.Request): HTTP request object. Returns: @@ -27,13 +31,20 @@ def contact(request): if form.validate_on_submit(): try: - contact, created = create_or_update_contact(form.email, form.firstName, form.lastName) - create_ticket(form.message, "Octue contact form submission", contact) - + response = forward_contact_email( + form.data["firstName"], + form.data["lastName"], + form.data["email"], + form.data["message"], + ) + response.raise_for_status() + logger.info("Sent contact form request via mailgun") + + # Blanket exception because we don't want to show internal errors to customers + # pylint: disable-next=bare-except except: - # Blanket exception because we don't want to show internal errors to customers # Log the form data so it's retrievable - logger.exception(f"An error occurred. Form data was: {form.data}") + logger.exception("An error occurred. Form data was: %s", form.data) return form.data, 200 @@ -41,9 +52,10 @@ def contact(request): return clean_errors(form.errors), 400 +@functions_framework.http @cors_enabled def subscribe(request): - """Subscribes a user to the mailing list in HubSpot + """Subscribes a user to the main mailchimp list Args: request (flask.Request): HTTP request object. Returns: @@ -59,13 +71,14 @@ def subscribe(request): if form.validate_on_submit(): try: - create_or_update_contact(form.email) - subscribe_contact(form.email) + add_or_update_subscriber(form.data["email"]) + logger.info("Added / updated subscriber in mailchimp") + # Blanket exception because we don't want to show internal errors to customers + # pylint: disable-next=bare-except except: - # Blanket exception because we don't want to show internal errors to customers # Log the form data so it's retrievable - logger.exception(f"An error occurred. Form data was: {form.data}") + logger.exception("An error occurred. Form data was: %s", form.data) return form.data, 200 diff --git a/gcloud/functions/requirements.txt b/gcloud/functions/requirements.txt new file mode 100644 index 0000000..3091e76 --- /dev/null +++ b/gcloud/functions/requirements.txt @@ -0,0 +1,3 @@ +flask_wtf +wtforms[email] +functions_framework==3.4.0 diff --git a/gcloud/functions/test_contact.py b/gcloud/functions/test_contact.py new file mode 100644 index 0000000..548981a --- /dev/null +++ b/gcloud/functions/test_contact.py @@ -0,0 +1,18 @@ +import requests + + +def test_contact(): + """Test that the contact function works as expected + :return: + """ + data = { + "email": "me@octue.com", + "firstName": "Me", + "lastName": "Octue", + "message": "Hello", + } + response = requests.post("http://127.0.0.1:8080", data=data, timeout=5) + response.raise_for_status() + + +test_contact() diff --git a/gcloud/functions/test_subscribe.py b/gcloud/functions/test_subscribe.py new file mode 100644 index 0000000..f77abac --- /dev/null +++ b/gcloud/functions/test_subscribe.py @@ -0,0 +1,13 @@ +import requests + + +def test_subscribe(): + """Test that the subscribe function works as expected + :return: + """ + email = "you@octue.com" + response = requests.post("http://127.0.0.1:8080", data={"email": email}, timeout=5) + response.raise_for_status() + + +test_subscribe() diff --git a/mkver.conf b/mkver.conf deleted file mode 100644 index 472e3fb..0000000 --- a/mkver.conf +++ /dev/null @@ -1,43 +0,0 @@ -tagPrefix: "" -defaults { - tag: false - tagMessageFormat: "Release/{Tag}" - preReleaseFormat: "RC{PreReleaseNumber}" - buildMetaDataFormat: "{Branch}.{ShortHash}" - includeBuildMetaData: false - whenNoValidCommitMessages: NoIncrement - patches: [package.json] -} - -patches: [ - { - name: package.json - filePatterns: ["package.json"] - replacements: [ - { - find: "\"version\": \"{VersionRegex}\"" - replace: "\"version\": \"{Version}\"" - } - ] - } -] - -commitMessageActions: [ -# Disable major version increments while package is still in beta (i.e. keep the version below 1.0.0). -# { -# pattern: "BREAKING CHANGE|BREAKING-CHANGE" -# action: IncrementMajor -# } - -# All new features require a minor version increase. - { - pattern: "FEA:" - action: IncrementMinor - } - -# Any other changes, including bug fixes, require a patch version increase. - { - pattern: ".*" - action: IncrementPatch - } -] diff --git a/package.json b/package.json index 0aeb6ea..a63ba68 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "planex-site", "private": true, "description": "The octue website frontend", - "version": "0.1.5", + "version": "0.1.6", "author": "Tom Clark ", "dependencies": { "@fontsource/open-sans": "^4.5.5",