Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Functionality for adding feast manually via modal as of #50 #57

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
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
16 changes: 15 additions & 1 deletion forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,27 @@
from wtforms.widgets import TextArea

from models import Feast, Music
from utils import logger

hymns = [('', 'None')] + [(h.ref, f'{h.ref} - {h.title}') for h in
Music.neh_hymns()]

translations = [('', 'None')] + [(h.translation, f'{h.translation}') for h in
Music.neh_hymns()]

class FeastForm(FlaskForm):
# Required fields
name = StringField('Name', validators=[DataRequired()])
month = StringField('Month', validators=[DataRequired()])
day = StringField('Day', validators=[DataRequired()])
collect = StringField('Collect', widget=TextArea(), validators=[DataRequired()])
# Optional fields
introit = StringField('Introit', widget=TextArea())
offertory = StringField('Offertory', widget=TextArea())
tract = StringField('Tract', widget=TextArea())
gradual = StringField('Gradual', widget=TextArea())
alleluia = StringField('Alleluia', widget=TextArea())

class AnthemForm(Form):
title = StringField('Anthem')
composer = StringField('Anthem composer')
Expand All @@ -33,7 +47,7 @@ class PewSheetForm(FlaskForm):
)
secondary_feasts = SelectMultipleField(
'Secondary Feasts',
choices=[('', '')] + feast_choices,
choices=[('', '')] + feast_choices, validate_choice=False
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no validation?

Copy link
Collaborator Author

@MonEstCha MonEstCha Jan 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the newly added feast is selected from the dropdown, 'Not a valid choice' is displaying. The workaround to this is no validation, see pallets-eco/wtforms#434.

)
date = DateField('Date', validators=[DataRequired()])
time = TimeField('Time', validators=[DataRequired()])
Expand Down
43 changes: 41 additions & 2 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
from models_base import get

if typing.TYPE_CHECKING:
from forms import PewSheetForm, AnthemForm
from forms import PewSheetForm, AnthemForm, FeastForm

from utils import get_neh_df, advent, closest_sunday_to, NoPandasError, logger
from utils import get_neh_df, advent, closest_sunday_to, NoPandasError, logger, formatFeastName

feasts_fields = ['name', 'month', 'day', 'coeaster', 'coadvent',
'introit', 'collect', 'epistle_ref', 'epistle',
Expand All @@ -38,9 +38,48 @@ def _none2datemax(d: Optional[dt.date]) -> dt.date:

@define
class Feast:
optionalFields = ['introit', 'gat', 'gradual', 'alleluia', 'tract', 'offertory']

def infer_gat(gradual, alleluia):
gat = None
if gradual:
gat = 'Gradual'
if alleluia:
if gat is not None:
gat = gat + ' and Alleuia'
else:
gat = 'Alleuia'
if gat is not None:
gat = gat + ' Proper'
return gat

@classmethod
def from_yaml(cls, slug):
return _feast_from_yaml(slug)

@classmethod
def to_yaml(cls, feastForm: 'FeastForm'):
name = formatFeastName(feastForm.name.data)
gat = cls.infer_gat(feastForm.gradual.data, feastForm.alleluia.data)
with open((DATA_DIR / name).with_suffix('.yaml'), "w") as f:
f.write('name: ' + feastForm.name.data + '\n')
#TODO: check validity of day and month,
# i.e. generate Date instance with Date(str) and check if it exists
f.write('month: ' + feastForm.month.data + '\n')
f.write('day: ' + feastForm.day.data + '\n')
f.write('collect: ' + feastForm.collect.data + '\n')
for field in cls.optionalFields:
if field != 'gat':
if feastForm[field].data:
f.write(field + ': ' + feastForm[field].data + '\n')
else:
if gat is not None:
f.write('gat: ' + gat + '\n')

with open(DATA_DIR / '_list.txt', "a") as f:
f.write('\n' + name)



@classmethod
def all(cls):
Expand Down
4 changes: 4 additions & 0 deletions pypew.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import views
from utils import logger

from flask_wtf.csrf import CSRFProtect

load_dotenv()


Expand Down Expand Up @@ -54,6 +56,7 @@ def create_app(pypew: Optional[PyPew] = None, **kwargs) -> Flask:
app.add_url_rule('/feast/api/<slug>', 'feast_detail_api', views.feast_detail_api)
app.add_url_rule('/feast/api/<slug>/date', 'feast_date_api', views.feast_date_api)
app.add_url_rule('/feast/<slug>/docx', 'feast_docx_view', views.feast_docx_view)
app.add_url_rule('/pewSheet/feastCreation', 'create_feast', views.create_feast, methods=['GET'])
app.add_url_rule('/pewSheet', 'pew_sheet_create_view', views.pew_sheet_create_view, methods=['GET'])
app.add_url_rule('/pewSheet/docx', 'pew_sheet_docx_view', views.pew_sheet_docx_view, methods=['GET'])
app.add_url_rule('/pewSheet/clearHistory',
Expand Down Expand Up @@ -101,6 +104,7 @@ def main(argv: Optional[Sequence[str]] = None) -> None:

pypew = PyPew()
app = create_app(pypew)
csrf = CSRFProtect(app)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice


if args.debug:
# noinspection FlaskDebugMode
Expand Down
24 changes: 24 additions & 0 deletions templates/createFeast.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<form method="get" id="createFeastForm" action="{{ url_for('create_feast') }}">
{{ feastForm.csrf_token }}
{% for field in feastFormFields %}
<div class="row">
<div class="col-sm-3">
{{ feastForm[field].label(class='col-form-label') }}
</div>
<div class="col-sm-9">
{{ feastForm[field](class='form-control', id='feast-' + field) }}
{% if feastForm[field].errors %}
{% for error in feastForm[field].errors %}
<small class="font-small text-danger">
{{ error }}
</small>
{% endfor %}
{% endif %}
</div>
</div>
{% endfor %}
<div>
<button id="cancel" type="reset">Cancel</button>
<input id="create-feast" type="submit" value="Create Feast">
</div>
</form>
142 changes: 140 additions & 2 deletions templates/serviceForm.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
<dialog id="feast-dialog">
{% include "createFeast.html" %}
</dialog>
<form action="{{ url_for('pew_sheet_create_view') }}"
class="px-5">
<div class="row mb-3">
Expand Down Expand Up @@ -32,7 +35,7 @@ <h3 id="titleH3"></h3>
<div class="row mb-3">
{{ form.secondary_feasts.label(class='col-sm-2 col-form-label') }}
<div class="col-sm-5">
{{ form.secondary_feasts(class='form-select') }}
{{ form.secondary_feasts(class='form-select', formnovalidate=True) }}
{% if form.secondary_feasts.errors %}
{% for error in form.secondary_feasts.errors %}
<small class="font-small text-danger">
Expand All @@ -41,9 +44,11 @@ <h3 id="titleH3"></h3>
{% endfor %}
{% endif %}
</div>
<div class="col-sm-3">
<button id="add-feast-dialog" type="button">Add Feast</button>
</div>
</div>


<div class="row mb-3">
{{ form.date.label(class='col-sm-1 col-form-label') }}
<div class="col">
Expand Down Expand Up @@ -183,3 +188,136 @@ <h3 id="titleH3"></h3>
</form>

<script src="{{ url_for('static', filename='serviceForm.js') }}"></script>
<script>

// Note: logic needs to be able to access python API and hence was placed here
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, all you're doing is injecting the url_for...
I'm always a bit iffy about injecting Jinja stuff into JavaScript, generally speaking it's insecure; if an attacker manages to get the server to send something nasty then it will execute as JavaScript on a client's browser.

You could hardcode the URL, but maybe a better way to pass data from the Jinja2 backend to the JS would be something like

<input id="myUrl" type="hidden" value="{{ url_for(...) }}">
<script> // put this in a separate file
const myUrl = document.getElementById("myUrl").value;
</script>

You could alternatively use the data-... attributes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, thanks. I've seen something similar in the pewSheet.html, which then would need fixing as well? What do you mean by the data-... attributes?

Copy link
Owner

@jftsang jftsang Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-... attributes

https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Solve_HTML_problems/Use_data_attributes

e.g.

<p id="foo" data-url="{{ url_for(...) }}"></p>
<script>
const myUrl = document.getElementById("foo").dataset.url
</script>

var feastsArray = [];
var monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ];
// source: https://www.freecodecamp.org/news/format-dates-with-ordinal-number-suffixes-javascript/
const nthNumber = (number) => {
if (number > 3 && number < 21) return "th";
switch (number % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
};
var feastName = document.getElementById("feast-name");
var feastDay = document.getElementById("feast-day");
var feastMonth = document.getElementById("feast-month");
feastName.addEventListener('change', checkForNameDuplicates);
feastDay.addEventListener('change', checkForDateDuplicates);
feastMonth.addEventListener('change', checkForDateDuplicates);

function checkForDateDuplicates() {
var day = feastDay.value? parseInt(feastDay.value) : 0;
var month = feastMonth.value? parseInt(feastMonth.value): 0;
if(day == 0 || month == 0){
return;
}
console.log(nthNumber(day) + ', ' + monthNames[month-1]);
var similarityDetected = false;
feastsArray.every(function (el) {
// compare date of feast to input month and day
var dateComponents = el.next.split(' ');
if(dateComponents.length == 4 && (day + nthNumber(day) == dateComponents[1]) && monthNames[month-1] == dateComponents[2]){
similarityDetected = true;
return false;
}

return true;
});

if (similarityDetected) {
alert("You're creating a feast for " + day + "." + month + "., but there already exists a feast on this date; are you accidentally creating a duplicate?");
}
}

function checkForNameDuplicates() {
var input = feastName.value;
var words = input.split(' ');
var similarityCt = 0, similarFeast = '';
feastsArray.every(function (el) {
if (similarityCt > 1) {
return false;
}
similarityCt = 0;
words.every(function (word) {
if (similarityCt > 1) {
return false;
}
if (el.name.includes(word)) {
if (word != 'and') {
similarityCt++;
}
if(similarityCt == 2){
similarFeast = el.name;
}
}
return true;
});
return true;
});

if (similarityCt > 1) {
alert("You're inputting " + input + ", but " + similarFeast + " already exists; are you accidentally creating a duplicate?");
}
}

const dialog = document.getElementById("feast-dialog");
const addFeastBtn = document.getElementById('add-feast-dialog');
addFeastBtn.onclick = () => {
dialog.showModal();
};

const cancelButton = document.getElementById("cancel");
cancelButton.onclick = () => {
dialog.close("Feast creation cancelled");
};

function updateFeasts(){
// refresh feasts
console.log('retrieved data from database');
const feastsApiUrl = "{{ url_for('feast_upcoming_api') }}";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see note above

fetch(feastsApiUrl).then(response => response.json())
.then(function(response) {
// clear choice options
var secondaryFeastsField = document.getElementById('secondary_feasts');
secondaryFeastsField.replaceChildren();
// recreate choice options
response.forEach(element => {
var opt = document.createElement('option');
opt.text = element.name;
opt.value = element.slug;
secondaryFeastsField.add(opt);
});
feastsArray = response;
});
}

const createFeastBtn = document.getElementById("create-feast");
createFeastBtn.onclick = () => {
try {
dialog.close("Feast creation request submitted!");
// wait some time for database update
const myTimeout = setTimeout(updateFeasts, 2000);
} catch (error) {
dialog.close("Feast creation aborted due to errors!");
}
};

dialog.addEventListener('close', function (e) {
e.preventDefault();
console.log(dialog.returnValue);
});

// load secondary feast options on init
(() => {
updateFeasts();
})();
</script>
4 changes: 4 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,7 @@ def advent(year: int) -> date:
return christmas - timedelta(days=christmas_dow + 7*3)
else:
return christmas - timedelta(7 * 4)

def formatFeastName(name: str) -> str:
nameComponents = name.split()
return ('-').join(nameComponents)
14 changes: 10 additions & 4 deletions views/pew_sheet_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
send_file, session, url_for)
from werkzeug.datastructures import ImmutableMultiDict

from forms import PewSheetForm
from forms import PewSheetForm, FeastForm
from models import Feast, Service
from utils import cache_dir, logger

__all__ = ['pew_sheet_create_view', 'pew_sheet_clear_history_endpoint', 'pew_sheet_docx_view']
__all__ = ['pew_sheet_create_view', 'pew_sheet_clear_history_endpoint', 'pew_sheet_docx_view', 'create_feast']

dotenv.load_dotenv()
COOKIE_NAME = os.environ.get('COOKIE_NAME', 'previousPewSheets')

def create_feast():
feastForm = FeastForm(request.args)
print('Into feast creation...')
Feast.to_yaml(feastForm)
return make_response('', 204)

def pew_sheet_create_view():
feastFormFields = ["name", "month", "day", "collect", "introit", "offertory", "tract", "gradual", "alleluia"]
feastForm = FeastForm(request.args)
form = PewSheetForm(request.args)
if not form.primary_feast.data:
form.primary_feast.data = Feast.next().slug
Expand Down Expand Up @@ -49,10 +56,9 @@ def pew_sheet_create_view():
pass

previous_services.sort(key=lambda args_service: args_service[1].date)

return render_template(
'pewSheet.html', form=form, service=service,
previous_services=previous_services
previous_services=previous_services, feastForm=feastForm, feastFormFields=feastFormFields
)


Expand Down
Loading