Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ files:
course_attachment_path: 'courseattachments'
proposal_attachment_path: 'proposalattachments'

campusgroups:
sandbox:
url: https://berea-sandbox.campusgroups.com
secret: 'addkeyhere'
key: 'addkeyhere'
school: 'berea-sandbox'
production:
url: https://berea.campusgroups.com
secret: 'addkeyhere'
key: 'addkeyhere'
school: 'berea'

MAIL_ENABLED: True
MAIL_SERVER: "smtp.gmail.com" # use smtp.gmail.com for gmail, mail.berea.edu for Berea
MAIL_PORT: 465 # use port 465 for gmail, use 25 for Berea
Expand Down Expand Up @@ -206,4 +218,4 @@ contributors:
- name: "Nahom Mesfin Terrefe"
role: "Software Engineer"
- name: "Dayton Conwell"
role: "Software Engineer"
role: "Software Engineer"
7 changes: 7 additions & 0 deletions app/controllers/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ def eventDisplay(eventId):

# Event Edit
if 'edit' in rule.rule:
event.isCampusGroupsSynced = False
event.save(only=[Event.isCampusGroupsSynced])
return render_template("events/createEvent.html",
eventData = eventData,
termList = Term.select().order_by(Term.termOrder),
Expand Down Expand Up @@ -367,6 +369,11 @@ def eventDisplay(eventId):

userParticipatedTrainingEvents = getParticipationStatusForTrainings(eventData['program'], [g.current_user], g.current_term)

# CampusGroups integration
if event.campusGroupsId:
eventData["campusGroupsURL"] = event.campusGroupsURL
eventData["campusGroupsId"] = event.campusGroupsId
eventData["isCampusGroupsSynced"] = event.isCampusGroupsSynced
return render_template("events/eventView.html",
eventData=eventData,
event=event,
Expand Down
35 changes: 34 additions & 1 deletion app/controllers/events/routes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from flask import Flask, redirect, flash, url_for, request, render_template, g, json, abort, session
from app.logic.campusGroups import CampusGroups
from flask import Flask, redirect, flash, url_for, request, render_template, g, json, abort, session, jsonify
import requests, xmltodict
from datetime import datetime
from peewee import DoesNotExist

Expand All @@ -12,6 +14,9 @@
from app.logic.emailHandler import EmailHandler
from app.logic.participants import addBnumberAsParticipant

from app import app


@events_bp.route('/event/<eventid>/scannerentry', methods=['GET'])
def loadKiosk(eventid):
"""Renders kiosk for specified event."""
Expand Down Expand Up @@ -48,3 +53,31 @@ def kioskSignin():
except Exception as e:
print("Error in Kiosk Page", e)
return "", 500

@events_bp.route('/retrieveEvents', methods=['GET'])
def retrieveEvents():
try:
cg = CampusGroups()
events = cg.getEvents()
return events
except requests.exceptions.RequestException as e:
print("Error retrieving data from campusgroups: \n", e)
return "", 500


@events_bp.route('/addEventToCampusGroups/<event_id>', methods=['GET'])
def addEventToCampusGroups(event_id):
if app.env == "production":
campusGroupsEnv = "production"
else:
campusGroupsEnv = "sandbox"

try:
cg = CampusGroups(campusGroupsEnv)
data = cg.parseEventData(event_id)
response = cg.addEvent(data)
return response # FIXME Return a better response, or parse this in the front end
except requests.exceptions.RequestException as e:
print("Error retrieving data from campusgroups: \n", e)
return "", 500

187 changes: 187 additions & 0 deletions app/logic/campusGroups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
from datetime import datetime
from os import abort
from flask import abort, jsonify
import requests, xmltodict
import xml.etree.ElementTree as ET

from app import app
from app.models.event import Event

EVENT_FIELDS = [
"cg_event_id", # Integer — 0 to create, existing id to update
"cg_group_acronym", # String
"external_event_id", # String[100]
"event_coordinator", # String[300], email
"event_name", # String
"quick_description", # String[4294967295]
"event_type", # String[255]
"event_start_date", # String[16]
"event_start_time", # String[16]
"event_end_date", # String[16]
"event_end_time", # String[16]
"event_location", # String[255]
"room_id", # Integer
"event_display_to", # Integer - see docs for magic numbers
"allow_rsvp", # Integer, 0 or 1
"event_open_to", # Integer - see docs for magic numbers
"delete_event", # Integer, 0 or 1
"hide_from_events_slider", # Integer, 0 or 1
"force_display_on_rooms_schedule", # Integer, 0 or 1
"location_type", # Integer, 0/2/3
"event_audience", # Integer
]

REQUIRED_FIELDS = [
"cg_group_acronym",
"event_name",
"event_start_date",
"event_start_time",
"event_end_date",
"event_end_time"
]

# The following are also required fields, but are NOT passed into CampusGroups objects (they are retrieved from the config files):
# "api_key", # String[100], Required
# "timestamp", # String[20], yyyy-MM-ddTHH:mm:ssZ (UTC)
# "school", # String[30]

class CampusGroups:
def __init__(self, campusGroupsEnv = "sandbox"):
self.url = app.config["campusgroups"][campusGroupsEnv]["url"]
self.secret = app.config["campusgroups"][campusGroupsEnv]["secret"]
self.key = app.config["campusgroups"][campusGroupsEnv]["key"]
self.school = app.config["campusgroups"][campusGroupsEnv]["school"]
self.headers = {'X-CG-API-Secret': self.secret}
self.event = None

def getEvents(self):
"""
Retrieve events from CampusGroups using the RSS feed.
"""
now = datetime.now()
ts_now = now.isoformat().replace('+00:00', 'Z')
rss_events_url = f'{self.url}/rss_events?ts={ts_now}&preauth={self.key}'
response = requests.get(rss_events_url, headers = self.headers)
response.raise_for_status()
data_dict = xmltodict.parse(response.text)
return jsonify(data_dict)


def addEvent(self, eventData):
"""
Add or update an event in CampusGroups using the CreateUpdateEvent SOAP API.
"""
createUpdateEvent_url = "https://berea-sandbox.campusgroups.com/WebServices/campusgroups.asmx?op=CreateUpdateEvent"
xmlOut = self.build_event_xml(eventData)

self.headers["SOAPAction"] = "http://campusgroups.com/CreateUpdateEvent"
self.headers["Content-Type"] = "text/xml; charset=utf-8"


response = requests.post(createUpdateEvent_url, data=xmlOut, headers=self.headers, timeout=15)
response.raise_for_status()

return self.parse_event_response(response.content)


def build_event_xml(self, payload):
"""
Build the SOAP XML body for CreateUpdateEvent from a dictionary payload.
"""
missing = [f for f in REQUIRED_FIELDS if not payload.get(f)]
if missing:
raise ValueError(f"Missing required field(s): {', '.join(missing)}")

envelope = ET.Element(
"soap12:Envelope",
{
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xmlns:xsd": "http://www.w3.org/2001/XMLSchema",
"xmlns:soap12": "http://www.w3.org/2003/05/soap-envelope",
},
)
body = ET.SubElement(envelope, "soap12:Body")
op = ET.SubElement(body, "CreateUpdateEvent", {"xmlns": "http://campusgroups.com/"})

# Always-required auth/context fields
now = datetime.now()
ts_now = now.isoformat().replace('+00:00', 'Z')

ET.SubElement(op, "timestamp").text = (ts_now)
ET.SubElement(op, "school").text = self.school
ET.SubElement(op, "api_key").text = self.key
# Event-specific fields — only send what's provided so partial
# updates don't clobber existing values.
for field in EVENT_FIELDS:
value = payload.get(field)
if value is not None and value != "":
ET.SubElement(op, field).text = str(value)

xml_bytes = ET.tostring(envelope, encoding="utf-8")
return b'<?xml version="1.0" encoding="utf-8"?>' + xml_bytes

def parseEventData(self, eventID):
"""
Parse the event data from Celts-link database into a dictionary.
"""

# TODO handle recurring events
try:
self.event = Event.get_by_id(eventID)
except Event.DoesNotExist:
abort(404, description=f"No events with ID {eventID} found.")
data = {}

if self.event.campusGroupsId is None:
data["cg_event_id"] = 0 # create a new event
else:
data["cg_event_id"] = self.event.campusGroupsId #update an existing event

data["cg_group_acronym"] = "Celts" # event.program?
data["external_event_id"] = self.event.id
data["event_coordinator"] = self.event.contactEmail
data["event_name"] = self.event.name
data["quick_description"] = self.event.description
data["event_type"] = "Academic"
data["event_start_date"] = self.event.startDate.strftime("%Y-%m-%d")
data["event_start_time"] = self.event.timeStart.strftime("%H:%M")
data["event_end_date"] = self.event.startDate.strftime("%Y-%m-%d")
data["event_end_time"] = self.event.timeEnd.strftime("%H:%M")
data["event_location"] = self.event.location

# Check CampusGroups API documentation for magic numbers:
data["event_display_to"] = 0
data["allow_rsvp"] = 1
data["event_open_to"] = 1
data["delete_event"] = 0
data["hide_from_events_slider"] = 0
data["force_display_on_rooms_schedule"] = 0
data["location_type"] = 0

return data

def parse_event_response(self, xml_content):
"""
Pull cg_event_id / message / message_code out of the SOAP response,
tolerating either the namespaced or bare tag form.
"""

responseData = xmltodict.parse(xml_content)

def find_text(tag):
el = responseData['soap:Envelope']['soap:Body']['CreateUpdateEventResponse']['CreateUpdateEventResult'].get(tag)
return el if el is not None else None

response = {
"cg_event_id": find_text("cg_event_id"),
"message": find_text("message"),
"message_code": find_text("message_code"),
}

if response["cg_event_id"]:
self.event.campusGroupsId = response["cg_event_id"]
self.event.campusGroupsURL = f"{self.url}/celts/rsvp_boot?id={response['cg_event_id']}"
self.event.isCampusGroupsSynced = True
self.event.save()

return response
3 changes: 3 additions & 0 deletions app/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class Event(baseModel):
isCanceled = BooleanField(default=False)
deletionDate = DateTimeField(null=True)
deletedBy = TextField(null=True)
campusGroupsId = IntegerField(null=True)
campusGroupsURL = CharField(null=True)
isCampusGroupsSynced = BooleanField(default=False)

_spCache = "Empty"

Expand Down
19 changes: 19 additions & 0 deletions app/static/js/eventList.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,27 @@ $(document).ready(function(){
$(".no-upcoming").show()
}
}


});

$("#pushToCampusGroupsBtn").on("click", function(){
$.ajax({
url: "/addEventToCampusGroups/" + $("#pushToCampusGroupsBtn").val(),
type: "GET",
success: function(response) {
console.log(response)
alert("Event pushed to CampusGroups successfully!");
location.reload();
},
error: function(xhr, status, error) {
var response = JSON.parse(xhr.responseText);
alert("Error pushing event to CampusGroups: " + response);
}
})
});


function rsvpForEvent(eventID){
rsvpInfo = {id: eventID,
from: 'ajax'}
Expand Down
2 changes: 1 addition & 1 deletion app/templates/events/eventNav.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
<div class="btn-group">
<ul class="nav nav-tabs nav-fill mx-3 mb-3" id="pills-tab" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link {{ 'active' if tabName == 'view' }}" href="/event/{{event.id}}/view" role="tab" aria-selected="{{ 'true' if tabName == 'view' else 'false'}}">View Event</a>
<a class="nav-link {{ 'active' if tabName == 'view' }}" href="/event/{{event.id}}/view" role="tab" aria-selected="{{ 'true' if tabName == 'view' else 'false'}}">View Event {% if not event.isCampusGroupsSynced %}<i class="bi bi-arrow-up-circle" style="color: red;"> </i>{% endif %}</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {{ 'active' if tabName == 'edit' }}" href="/event/{{event.id}}/edit" role="tab" aria-selected="{{ 'true' if tabName == 'edit' else 'false'}}">Edit Event</a>
Expand Down
17 changes: 16 additions & 1 deletion app/templates/events/eventView.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,23 @@ <h5 id="time" style="margin: 0;">Time:</h5>
<div>
<h5 id="location"style="margin: 0;">Location:</h5>
<p style="margin: 0;">{{eventData.location}}</p>
</div>
</div>
</div>
<br>
<div class="d-flex align-top">
<i class="bi bi-arrow-bar-right" style="margin-right: 10px;"></i>
<div>
<h5 id="location" style="margin: 0;">CampusGroups:</h5>
{% if eventData.campusGroupsId %}
<p style="margin: 0;"><a href="{{eventData.campusGroupsURL|safe}}" target="_blank" >CampusGroups Event</a></p>
{% if not eventData.isCampusGroupsSynced %}
<i class="bi bi-arrow-up-circle" style="color: red;"> </i>Update event changes to CampusGroups? <button type="button" class="btn btn-primary" value="{{eventData.id}}" id="pushToCampusGroupsBtn">Push</button>
{% endif %}
{% else %}
Add this event to CampusGroups? <button type="button" class="btn btn-primary" value="{{eventData.id}}" id="pushToCampusGroupsBtn">Push</button>
{% endif %}
</div>
</div>
<br>
<div>
{% if eventData.isRsvpRequired %}
Expand Down
15 changes: 12 additions & 3 deletions database/reset_database.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ else
exit;
fi

# Determine SSL option - try --skip-ssl first, fall back to --ssl-mode=DISABLED if unsupported
# MariaDB and older MySQL versions support --skip-ssl
# Newer MySQL 8.0.26+ use --ssl-mode=DISABLED
SSL_OPTION="--skip-ssl"
mysql -u root -proot --skip-ssl --execute="SELECT 1;" > /dev/null 2>&1
if [ $? -ne 0 ]; then
SSL_OPTION="--ssl-mode=DISABLED"
fi

########### Recreate Database Schema ###########
echo "Dropping databases"
mysql -u root -proot --execute="DROP DATABASE \`celts\`; DROP USER 'celts_user';"
mysql -u root -proot $SSL_OPTION --execute="DROP DATABASE \`celts\`; DROP USER 'celts_user';"

echo "Recreating databases and users"
mysql -u root -proot --execute="CREATE DATABASE IF NOT EXISTS \`celts\`; CREATE USER IF NOT EXISTS 'celts_user'@'%' IDENTIFIED BY 'password'; GRANT ALL PRIVILEGES ON *.* TO 'celts_user'@'%';"
mysql -u root -proot $SSL_OPTION --execute="CREATE DATABASE IF NOT EXISTS \`celts\`; CREATE USER IF NOT EXISTS 'celts_user'@'%' IDENTIFIED BY 'password'; GRANT ALL PRIVILEGES ON *.* TO 'celts_user'@'%';"


# remove ahead of time in case we didn't clean up last time
Expand All @@ -42,7 +51,7 @@ rm -rf migrations.json
echo -n "Creating database objects"
if [ $BACKUP -eq 1 ]; then
echo " from backup"
mysql -u root -proot celts < prod-backup.sql
mysql -u root -proot $SSL_OPTION celts < prod-backup.sql
else
echo " empty"
./migrate_db.sh no-backup
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,5 @@ Werkzeug==3.1.3
wsproto==1.2.0
WTForms==3.2.1
xlsxwriter==3.2.5
zipp==3.23.0
xmltodict==1.0.4
zipp==3.23.0
Loading
Loading