Skip to content

Commit

Permalink
Merge pull request #9 from bb-Ricardo/development
Browse files Browse the repository at this point in the history
v1.1.0 changes
  • Loading branch information
bb-Ricardo committed Apr 4, 2023
2 parents 374fc07 + c3e6e70 commit 310ad99
Show file tree
Hide file tree
Showing 15 changed files with 747 additions and 5 deletions.
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,82 @@ All options are described in the example file.
After starting the API the first time it will add additional fields to each event.
Such as a choice for the hosting Kennel or amount of Hash Cash.

## Listmonk support to post event
It is possible to post an event directly to via [Listmonk](https://github.com/knadh/listmonk) to a mailing list.

### Requirements
* a running listmonk instance
* WordPress Advanced Custom Fields plugin
* Wordpress WPCode Plugin

First both WordPress plugins should be installed.

Using the `Advanced Custom Fields` we add a new Field Group `Run Announcement`. There we add a new field `Mailing List`.
* Type: Message
* Label: Mailing List
* Name: mailing_list
* Message:
```
<div>
<button
id="send-to-mailing-list-button"
type="button"
class="button-secondary send-to-mailing-list-button"
id="refresh-cache">Send/Update Event to Mailing List
</button>
</div>
```

In the bottom we can find `Settings`.
* Rules:
1. Post Typ
2. is equal to
3. Event
* Representation:
1. Style: Standard
2. Position: Side
3. Label Placement: Top
4. Instruction placement: Below
5. Order No: 2

* Now press `Save Changes`

After that we switch to the `WPCode` plugin and a Javascript function to this button we just created.
Here we add a new `Send Mailinglist` code snippet of type `PHP Snipet` with this content:
```php
/* Inline script printed out in the header */
add_action('admin_footer', 'tutsplus_add_script_wp_head');
function tutsplus_add_script_wp_head() {
?>
<script id="updateMailingList" type="text/javascript">
document.querySelector( '.send-to-mailing-list-button' ).addEventListener( 'click', function( e ) {
var xhttp = new XMLHttpRequest();
var params = {
user: "<?php echo get_current_user_id(); ?>",
token: "<?php echo wp_get_session_token(); ?>"
}
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
console.log(this.responseText);
}
};
xhttp.open("POST", "/api/v1/send-newsletter/<?php echo get_the_ID(); ?>" , true);
xhttp.setRequestHeader('Content-type', 'application/json')
xhttp.send(JSON.stringify(params));
xhttp.onload = function() {
// Do whatever with response
alert("Mailing List request status: " + xhttp.responseText)
}
} );
</script>
<?php
}
```
With Listmonk running on the same hos, sending an event via Mailing list is just a button press away.

## License
>You can check out the full license [here](LICENSE.txt)
Expand Down
13 changes: 13 additions & 0 deletions api/factory/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ def get_hash_runs(params: HashParams) -> List[Hash]:
log.error(f"DB query should return a list, got {type(posts)}")
return return_list

if len(posts) == 0:
return return_list

post_meta = conn.get_posts_meta(post_ids)
event_manager_form_fields = php_deserialize(conn.get_config_item("event_manager_submit_event_form_fields"))

Expand Down Expand Up @@ -173,6 +176,7 @@ def get_hash_runs(params: HashParams) -> List[Hash]:
"geo_lat": post_attr.get("geolocation_lat"),
"geo_long": post_attr.get("geolocation_long"),
"geo_location_name": post_attr.get("geolocation_formatted_address"),
"geo_map_url": post_attr.get("_hash_geo_map_url"),
"location_name": post_attr.get("_event_location"),
"location_additional_info": post_attr.get("_hash_location_specifics"),
"facebook_group_id": config.app_settings.default_facebook_group_id,
Expand Down Expand Up @@ -243,6 +247,15 @@ def get_hash_runs(params: HashParams) -> List[Hash]:
if event_attributes is not None and isinstance(event_attributes, list):
hash_data["event_attributes"] = event_attributes

# handle geo_map_url
if hash_data.get("geo_map_url") is None and \
hash_data.get("geo_lat") is not None and hash_data.get("geo_long") is not None:

hash_data["geo_map_url"] = config.app_settings.maps_url_template.format(
lat=hash_data.get("geo_lat"),
long=hash_data.get("geo_long")
)

# parse event data
try:
run = Hash(**hash_data)
Expand Down
8 changes: 8 additions & 0 deletions api/models/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ class APITokenValidationFailed(HTTPException):
"""
def __init__(self):
super().__init__(status_code=HTTP_403_FORBIDDEN, detail="API token validation failed")


class CredentialsInvalid(HTTPException):
"""
return a forbidden due to wrong API token
"""
def __init__(self):
super().__init__(status_code=HTTP_403_FORBIDDEN, detail="Credentials invalid")
20 changes: 19 additions & 1 deletion api/models/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
from pytz import utc
from enum import Enum

from pydantic import BaseModel, AnyHttpUrl, Field, validator, root_validator
from pydantic import BaseModel, AnyHttpUrl, Field, validator, root_validator, ValidationError
from pydantic.dataclasses import dataclass
from fastapi import Query
from fastapi.exceptions import RequestValidationError

from config.hash import hash_attributes, hash_scope
from common.misc import format_slug
from common.log import get_logger
from api.models.exceptions import RequestValidationError

log = get_logger()


# generate from config.hash lists
HashAttributes = Enum('HashAttributes', {x: format_slug(x) for x in hash_attributes}, type=str)
Expand Down Expand Up @@ -175,4 +178,19 @@ def set_empty_strings_to_none(cls, value):
return None
return value

@validator("geo_map_url", always=True, pre=True)
def loose_type_geo_map_url(cls, value):
class SelfValidate(BaseModel):
url: AnyHttpUrl

if value is None:
return
try:
SelfValidate(url=value)
except ValidationError as e:
log.warning(f"Issues while validating 'geo_map_url' value '{value}': {e.errors()[0].get('msg')}")
return

return value

# EOF
57 changes: 57 additions & 0 deletions api/models/send_newsletter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022 Ricardo Bartels. All rights reserved.
#
# wordpress-hash-event-api
#
# This work is licensed under the terms of the MIT license.
# For a copy, see file LICENSE.txt included in this
# repository or visit: <https://opensource.org/licenses/MIT>.

from pydantic.dataclasses import dataclass
from typing import Any, Dict, List
from pydantic import BaseModel


@dataclass
class SendNewsletterParams:
user: int
token: str


class MailingListModel(BaseModel):
id: int
name: str


class ListmonkReturnData(BaseModel):
id: int
created_at: str
updated_at: str
views: int
clicks: int
bounces: int
lists: List[MailingListModel]
started_at: Any
to_send: int
sent: int
uuid: str
type: str
name: str
subject: str
from_email: str
body: str
altbody: Any
send_at: Any
status: str
content_type: str
tags: List
headers: List
template_id: int
messenger: str
archive: bool
archive_template_id: int
archive_meta: Dict[str, Any]


class ListmonkReturnDataList(BaseModel):
data: ListmonkReturnData
163 changes: 163 additions & 0 deletions api/routers/send_newsletter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022 Ricardo Bartels. All rights reserved.
#
# wordpress-hash-event-api
#
# This work is licensed under the terms of the MIT license.
# For a copy, see file LICENSE.txt included in this
# repository or visit: <https://opensource.org/licenses/MIT>.

import hashlib
from fastapi import APIRouter, HTTPException
from time import time

from api.models.run import HashParams
from api.models.send_newsletter import SendNewsletterParams, ListmonkReturnDataList
from api.models.exceptions import CredentialsInvalid
from api.factory.runs import get_hash_runs
from source.database import get_db_handler
from common.misc import php_deserialize, grab
from common.log import get_logger
from listmonk.handler import get_listmonk_handler


log = get_logger()

newsletter = APIRouter(
prefix="/send-newsletter",
tags=["newsletter"]
)


# noinspection PyShadowingBuiltins
@newsletter.post("/{post_id}", response_model=ListmonkReturnDataList, summary="post run",
description="post run via newsletter")
async def get_run(post_id: int, params: SendNewsletterParams):
"""
To post/update a run to listmonk
- **id**: The integer id of the desired run
"""

db_handler = get_db_handler()

# query user metadata from database
user_params = db_handler.get_usermeta(params.user)
session_tokens = None
for item in user_params:
if item.get("meta_key") == "session_tokens":
session_tokens = item.get("meta_value")

if session_tokens is None:
log.debug(f"No session_tokens metadata found in wordpress DB for user id {params.user}")
raise CredentialsInvalid

# use hashed session token to find session in database
hashed_token_sha256 = hashlib.sha256(params.token.encode()).hexdigest()
hashed_token_sha1 = hashlib.sha1(params.token.encode()).hexdigest()

session_data = None
try:
session_data = php_deserialize(session_tokens).get(hashed_token_sha256)
if session_data is None:
session_data = php_deserialize(session_tokens).get(hashed_token_sha1)
except Exception as e:
log.debug(f"PHP deserialization of 'session_tokens' failed: {e}")

if session_data is None:
log.debug(f"No session data found in wordpress DB for user id {params.user}")
raise CredentialsInvalid

# check if session has not expired
expiration_ts = session_data.get("expiration")
if not isinstance(expiration_ts, int):
log.debug(f"session data expiration is not an int: {session_data}")
raise CredentialsInvalid

if expiration_ts < int(time()):
log.debug(f"session already expired")
raise CredentialsInvalid

# all checks passed and user presented a valid session

# fetch post
# noinspection PyArgumentList
result = get_hash_runs(HashParams(id=post_id))

if result is None or len(result) == 0:
raise HTTPException(status_code=404, detail="Run not found")

event = result[0]

# fetch template from listmonk
listmonk_handler = get_listmonk_handler()
listmonk_template = listmonk_handler.get_template(listmonk_handler.config.body_template_id)

if listmonk_template is None:
raise HTTPException(status_code=404,
detail=f"Listmonk template {listmonk_handler.config.body_template_id} not found")

template_body = grab(listmonk_template, "data.body")

if template_body is None:
template_body = event.event_description

# set all paragraph text to center
event.event_description = event.event_description.replace('<p>', '<p style="text-align: center;">')

# use data from post and apply to template
try:
campaign_body = template_body.format(**event.__dict__)
except Exception as e:
log.error(f"Failed to format template: {e}")
raise HTTPException(status_code=500, detail=f"Failed to format template: {e}")

# fetch post metadata to check if newsletter has already been sent before
post_meta_data = db_handler.get_posts_meta([post_id])
post_campaign_id = None
subject_prefix = ""
for post_meta in post_meta_data:
if post_meta.get("meta_key") == "listmonk_campaign_id":
post_campaign_id = post_meta.get("meta_value")

if post_campaign_id is not None:
subject_prefix = "UPDATE: "

# prepare campaign data
campaign_data = {
"name": f"{subject_prefix}{event.event_name}",
"subject": f"{subject_prefix}[{event.kennel_name}] Run #{event.run_number}, "
f"{event.start_date:%A %d %B %Y, %H:%M} @ {event.location_name}",
"lists": listmonk_handler.config.list_ids,
"type": "regular",
"content_type": "html",
"body": campaign_body
}

if listmonk_handler.config.campaign_template_id is not None:
campaign_data["template_id"] = listmonk_handler.config.campaign_template_id

# create listmonk campaign
campaign_result = listmonk_handler.add_campaign(campaign_data)

if campaign_result is None:
raise HTTPException(status_code=503, detail=f"Upstream request failed")

campaign_id = grab(campaign_result, "data.id")

# send campaign
if listmonk_handler.config.send_campaign is True:
campaign_result = listmonk_handler.set_campaign_status(campaign_id, "running")

if campaign_result is None:
raise HTTPException(status_code=503, detail=f"Upstream request failed, unable to start campaign")

# write campaign id to WP database
if post_campaign_id is not None:
db_handler.update_post_meta(post_id, "listmonk_campaign_id", campaign_id)
else:
db_handler.add_post_meta(post_id, "listmonk_campaign_id", campaign_id)

return ListmonkReturnDataList(**campaign_result)

# EOF
Loading

0 comments on commit 310ad99

Please sign in to comment.