Commit 9e44bb98e1cae80bcac0444f8dd53e3d60a6e919
1 parent
56c7b457
Exists in
master
Support file uploads.
Showing
4 changed files
with
208 additions
and
26 deletions
Show diff stats
content.yml
... | ... | @@ -619,17 +619,27 @@ estimate: |
619 | 619 | last_name: We will never share your data with anyone. |
620 | 620 | origin_addresses: | |
621 | 621 | Use <code>en_US</code> city and country names, without diacritics. |
622 | - | |
622 | + <br> | |
623 | 623 | The comma matters. |
624 | 624 | <br> |
625 | 625 | This is either a home city and a country |
626 | 626 | or the cities and countries of the participants to the conference, meeting… |
627 | + origin_addresses_file: | | |
628 | + If you provide a file, we'll use it instead of the list on the left. | |
629 | + <br> | |
630 | + The spreadsheet's first sheet must have an <code>Address</code> column, | |
631 | + or a <code>City</code> and <code>Country</code> columns. | |
627 | 632 | destination_addresses: | |
628 | 633 | This is either the cities and countries to travel to |
629 | 634 | or the host city and country of the conference, meeting… |
630 | 635 | <br> |
631 | 636 | Please provide multiple cities and countries to compute the location |
632 | 637 | of the minimum emission. |
638 | + destination_addresses_file: | | |
639 | + If you provide a file, we'll use it instead of the list on the left. | |
640 | + <br> | |
641 | + The spreadsheet's first sheet must have an <code>Address</code> column, | |
642 | + or a <code>City</code> and <code>Country</code> columns. | |
633 | 643 | |
634 | 644 | # Labels accept HTML, but not markdown |
635 | 645 | # Descriptions accept neither, since we use the HTML title attribute |
... | ... | @@ -659,7 +669,7 @@ estimate: |
659 | 669 | description: | |
660 | 670 | One address per line, in the form `City, Country`. |
661 | 671 | Make sure your addresses are correctly spelled. |
662 | - # We MUST use the dumb CRLF pair for windows users | |
672 | + # We MUST use the dumb CRLF pair for dumb windows users | |
663 | 673 | placeholder: "Paris, France\r\nBerlin, Germany" |
664 | 674 | # placeholder: | |
665 | 675 | # Paris, France |
... | ... | @@ -671,12 +681,24 @@ estimate: |
671 | 681 | Make sure your addresses are correctly spelled. |
672 | 682 | placeholder: | |
673 | 683 | Washington, United States of America |
674 | - compute_optimal_destination: | |
675 | - label: | | |
676 | - Compute the destination city that will minimize emissions <br> | |
677 | - (useful when setting up a meeting/conference) | |
684 | + origin_addresses_file: | |
685 | + label: Origin Cities | |
678 | 686 | description: | |
679 | - We will only look through Cities specified in the Destination Cities. | |
687 | + Accepted files: CSV, XLS, XLSX. | |
688 | + We will use the Address column, or the City and Country columns. | |
689 | + error: Please use spreadsheet files only (CSV, XLS, XLSX) | |
690 | + destination_addresses_file: | |
691 | + label: Destination Cities | |
692 | + description: | | |
693 | + Accepted files: CSV, XLS, XLSX. | |
694 | + We will use the Address column, or the City and Country columns. | |
695 | + error: Please use spreadsheet files only (CSV, XLS, XLSX) | |
696 | +# compute_optimal_destination: | |
697 | +# label: | | |
698 | +# Compute the destination city that will minimize emissions <br> | |
699 | +# (useful when setting up a meeting/conference) | |
700 | +# description: | | |
701 | +# We will only look through Cities specified in the Destination Cities. | |
680 | 702 | use_atmosfair_rfi: |
681 | 703 | label: | |
682 | 704 | Use the <acronym title="Radiative Forcing Index">RFI</acronym> | ... | ... |
flaskr/controllers/main_controller.py
... | ... | @@ -21,6 +21,9 @@ import csv |
21 | 21 | # from io import StringIO |
22 | 22 | from cStringIO import StringIO |
23 | 23 | |
24 | +import pandas | |
25 | +from pandas.compat import StringIO as PandasStringIO | |
26 | + | |
24 | 27 | main = Blueprint('main', __name__) |
25 | 28 | |
26 | 29 | |
... | ... | @@ -56,6 +59,75 @@ def home(): |
56 | 59 | ) |
57 | 60 | |
58 | 61 | |
62 | +def gather_addresses(from_list, from_file): | |
63 | + addresses = [] | |
64 | + if from_file: | |
65 | + file_mimetype = from_file.mimetype | |
66 | + file_contents = from_file.read() | |
67 | + | |
68 | + rows_dicts = None | |
69 | + | |
70 | + if 'text/csv' == file_mimetype: | |
71 | + | |
72 | + rows_dicts = pandas \ | |
73 | + .read_csv(PandasStringIO(file_contents)) \ | |
74 | + .rename(str.lower, axis='columns') \ | |
75 | + .to_dict(orient="row") | |
76 | + | |
77 | + # Here are just *some* of the mimetypes that Microsoft's | |
78 | + # garbage spreadsheet files may have. | |
79 | + # application/vnd.ms-excel (official) | |
80 | + # application/msexcel | |
81 | + # application/x-msexcel | |
82 | + # application/x-ms-excel | |
83 | + # application/x-excel | |
84 | + # application/x-dos_ms_excel | |
85 | + # application/xls | |
86 | + # application/x-xls | |
87 | + # application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | |
88 | + # ... Let's check extension instead. | |
89 | + | |
90 | + elif from_file.filename.endswith('xls') \ | |
91 | + or from_file.filename.endswith('xlsx'): | |
92 | + | |
93 | + rows_dicts = pandas \ | |
94 | + .read_excel(PandasStringIO(file_contents)) \ | |
95 | + .rename(str.lower, axis='columns') \ | |
96 | + .to_dict(orient="row") | |
97 | + | |
98 | + # Python 3.7 only | |
99 | + # elif from_file.filename.endswith('ods'): | |
100 | + # | |
101 | + # rows_dicts = read_ods(PandasStringIO(file_contents), 1) \ | |
102 | + # .rename(str.lower, axis='columns') \ | |
103 | + # .to_dict(orient="row") | |
104 | + | |
105 | + if rows_dicts is not None: | |
106 | + for row_dict in rows_dicts: | |
107 | + if 'address' in row_dict: | |
108 | + addresses.append(row_dict['address']) | |
109 | + continue | |
110 | + address = None | |
111 | + if 'city' in row_dict: | |
112 | + address = row_dict['city'] | |
113 | + if 'country' in row_dict: | |
114 | + if address is None: | |
115 | + address = row_dict['country'] | |
116 | + else: | |
117 | + address += "," + row_dict['country'] | |
118 | + if address is not None: | |
119 | + addresses.append(address) | |
120 | + else: | |
121 | + pass # what should we do here? raise? | |
122 | + else: | |
123 | + pass # what should we do here? raise? | |
124 | + | |
125 | + else: | |
126 | + addresses = from_list.replace("\r", '').split("\n") | |
127 | + | |
128 | + return "\n".join(addresses) | |
129 | + | |
130 | + | |
59 | 131 | @main.route("/estimate", methods=["GET", "POST"]) |
60 | 132 | @main.route("/estimate.html", methods=["GET", "POST"]) |
61 | 133 | def estimate(): |
... | ... | @@ -72,10 +144,15 @@ def estimate(): |
72 | 144 | estimation.last_name = form.last_name.data |
73 | 145 | estimation.institution = form.institution.data |
74 | 146 | estimation.status = StatusEnum.pending |
75 | - estimation.origin_addresses = form.origin_addresses.data | |
76 | - estimation.destination_addresses = form.destination_addresses.data | |
147 | + estimation.origin_addresses = gather_addresses( | |
148 | + form.origin_addresses.data, | |
149 | + form.origin_addresses_file.data | |
150 | + ) | |
151 | + estimation.destination_addresses = gather_addresses( | |
152 | + form.destination_addresses.data, | |
153 | + form.destination_addresses_file.data | |
154 | + ) | |
77 | 155 | estimation.use_train_below_km = form.use_train_below_km.data |
78 | - # estimation.compute_optimal_destination = form.compute_optimal_destination.data | |
79 | 156 | models_slugs = [] |
80 | 157 | for model in models: |
81 | 158 | if getattr(form, 'use_model_%s' % model.slug).data: | ... | ... |
flaskr/forms.py
1 | 1 | from flask_wtf import FlaskForm |
2 | +from flask_wtf.file import FileAllowed | |
2 | 3 | from wtforms import \ |
3 | 4 | StringField, \ |
4 | 5 | PasswordField, \ |
5 | 6 | TextAreaField, \ |
6 | 7 | SelectField, \ |
7 | - BooleanField | |
8 | + BooleanField, \ | |
9 | + FileField | |
8 | 10 | from wtforms import validators |
11 | +from werkzeug.datastructures import FileStorage | |
9 | 12 | |
10 | 13 | from .models import User |
11 | 14 | from .content import content_dict as content |
... | ... | @@ -98,6 +101,32 @@ class EstimateForm(FlaskForm): |
98 | 101 | "placeholder": form_content['destination_addresses']['placeholder'] |
99 | 102 | }, |
100 | 103 | ) |
104 | + origin_addresses_file = FileField( | |
105 | + label=form_content['origin_addresses_file']['label'], | |
106 | + description=form_content['origin_addresses_file']['description'], | |
107 | + validators=[ | |
108 | + # We disabled validators because they bug with multiple FileFields | |
109 | + # validators.Optional(), | |
110 | + # FileAllowed( | |
111 | + # ['csv', 'xls', 'xlsx'], | |
112 | + # form_content['origin_addresses_file']['error'] | |
113 | + # ) | |
114 | + ], | |
115 | + ) | |
116 | + destination_addresses_file = FileField( | |
117 | + label=form_content['destination_addresses_file']['label'], | |
118 | + description=form_content['destination_addresses_file']['description'], | |
119 | + validators=[ | |
120 | + # We disabled validators because they bug with multiple FileFields | |
121 | + # validators.Optional(), | |
122 | + # FileAllowed( | |
123 | + # ['csv', 'xls', 'xlsx'], | |
124 | + # form_content['destination_addresses_file']['error'] | |
125 | + # ) | |
126 | + ], | |
127 | + ) | |
128 | + | |
129 | + upload_set = ['csv', 'xls', 'xlsx'] | |
101 | 130 | |
102 | 131 | # compute_optimal_destination = BooleanField( |
103 | 132 | # label=form_content['compute_optimal_destination']['label'], |
... | ... | @@ -132,11 +161,29 @@ class EstimateForm(FlaskForm): |
132 | 161 | |
133 | 162 | if not uses_at_least_one_model: |
134 | 163 | last_model = getattr(self, 'use_model_%s' % models[-1].slug) |
135 | - last_model.errors.append("Please select at least one model." | |
136 | - " " # It's been a while | |
137 | - "<em>What are you doing?</em>") | |
164 | + last_model.errors.append( | |
165 | + "Please select at least one plane model, " | |
166 | + "<em>even for train-only estimations.</em>" | |
167 | + ) | |
138 | 168 | return False |
139 | 169 | |
170 | + # Check uploaded files' extensions, if any | |
171 | + # We have to do this "by hand" because of a bug in flask wtf | |
172 | + if isinstance(self.origin_addresses_file.data, FileStorage): | |
173 | + fn = self.origin_addresses_file.data.filename.lower() | |
174 | + if fn and not any(fn.endswith('.' + x) for x in self.upload_set): | |
175 | + self.origin_addresses_file.errors.append( | |
176 | + form_content['origin_addresses_file']['error'] | |
177 | + ) | |
178 | + return False | |
179 | + if isinstance(self.destination_addresses_file.data, FileStorage): | |
180 | + fn = self.destination_addresses_file.data.filename.lower() | |
181 | + if fn and not any(fn.endswith('.' + x) for x in self.upload_set): | |
182 | + self.destination_addresses_file.errors.append( | |
183 | + form_content['destination_addresses_file']['error'] | |
184 | + ) | |
185 | + return False | |
186 | + | |
140 | 187 | return True |
141 | 188 | |
142 | 189 | ... | ... |
flaskr/templates/estimate.html
... | ... | @@ -14,7 +14,7 @@ |
14 | 14 | |
15 | 15 | |
16 | 16 | {#############################################################################} |
17 | -{# MACROS ######################################################################} | |
17 | +{# MACROS ####################################################################} | |
18 | 18 | |
19 | 19 | |
20 | 20 | {% macro render_field(field) %} |
... | ... | @@ -60,7 +60,7 @@ |
60 | 60 | <div class="row"> |
61 | 61 | <div class="col-md-2"></div> |
62 | 62 | <div class="col-md-8"> |
63 | - <form role="form" action="{{ url_for('.estimate') }}" method="post"> | |
63 | + <form role="form" action="{{ url_for('.estimate') }}" method="post" enctype="multipart/form-data"> | |
64 | 64 | {{ form.hidden_tag() }} |
65 | 65 | |
66 | 66 | {# <div class="form-group">#} |
... | ... | @@ -80,17 +80,53 @@ |
80 | 80 | <div class="form-group"> |
81 | 81 | {{ render_field(form.institution) }} |
82 | 82 | </div> |
83 | - <div class="form-group"> | |
84 | - {{ render_field(form.origin_addresses) }} | |
85 | - <small class="form-text text-muted"> | |
86 | - {{ content.estimate.help.origin_addresses | safe }} | |
87 | - </small> | |
83 | + | |
84 | + <div class="row"> | |
85 | + <div class="col-7"> | |
86 | + <div class="form-group"> | |
87 | + {{ render_field(form.origin_addresses) }} | |
88 | + <small class="form-text text-muted"> | |
89 | + {{ content.estimate.help.origin_addresses | safe }} | |
90 | + </small> | |
91 | + </div> | |
92 | + </div> | |
93 | + <div class="col-1"> | |
94 | + <br> | |
95 | + <br> | |
96 | + OR | |
97 | + </div> | |
98 | + <div class="col-4"> | |
99 | + <div class="form-group"> | |
100 | + {{ render_field(form.origin_addresses_file) }} | |
101 | + <small class="form-text text-muted"> | |
102 | + {{ content.estimate.help.origin_addresses_file | safe }} | |
103 | + </small> | |
104 | + </div> | |
105 | + </div> | |
88 | 106 | </div> |
89 | - <div class="form-group"> | |
90 | - {{ render_field(form.destination_addresses) }} | |
91 | - <small class="form-text text-muted"> | |
92 | - {{ content.estimate.help.destination_addresses | safe }} | |
93 | - </small> | |
107 | + | |
108 | + <div class="row"> | |
109 | + <div class="col-7"> | |
110 | + <div class="form-group"> | |
111 | + {{ render_field(form.destination_addresses) }} | |
112 | + <small class="form-text text-muted"> | |
113 | + {{ content.estimate.help.destination_addresses | safe }} | |
114 | + </small> | |
115 | + </div> | |
116 | + </div> | |
117 | + <div class="col-1"> | |
118 | + <br> | |
119 | + <br> | |
120 | + OR | |
121 | + </div> | |
122 | + <div class="col-4"> | |
123 | + <div class="form-group"> | |
124 | + {{ render_field(form.destination_addresses_file) }} | |
125 | + <small class="form-text text-muted"> | |
126 | + {{ content.estimate.help.destination_addresses_file | safe }} | |
127 | + </small> | |
128 | + </div> | |
129 | + </div> | |
94 | 130 | </div> |
95 | 131 | |
96 | 132 | {# <div class="form-check form-group">#} | ... | ... |