Commit 9e44bb98e1cae80bcac0444f8dd53e3d60a6e919

Authored by Antoine Goutenoir
1 parent 56c7b457
Exists in master

Support file uploads.

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   - &nbsp;
  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   - "&nbsp;&nbsp;" # 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">#}
... ...