forms.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. from functools import partial
  2. import itertools
  3. import urlparse
  4. import json
  5. import collections
  6. from flask import current_app
  7. from flask.ext.wtf import Form
  8. from wtforms import Form as InsecureForm
  9. from wtforms import (TextField, DateField, DecimalField, IntegerField,
  10. SelectField, SelectMultipleField, FieldList, FormField, BooleanField)
  11. from wtforms.widgets import (TextInput, ListWidget, html_params, HTMLString,
  12. CheckboxInput, Select, TextArea)
  13. from wtforms.validators import (DataRequired, Optional, URL, Email, Length,
  14. NumberRange, ValidationError, StopValidation)
  15. from flask.ext.babel import lazy_gettext as _, gettext
  16. from babel.support import LazyProxy
  17. from ispformat.validator import validate_geojson
  18. from .constants import STEPS, IPV6_SUPPORT
  19. from .models import ISP
  20. from .utils import check_geojson_spatialite, filesize_fmt
  21. class InputListWidget(ListWidget):
  22. def __call__(self, field, **kwargs):
  23. kwargs.setdefault('id', field.id)
  24. html = ['<%s %s>' % (self.html_tag, html_params(**kwargs))]
  25. for subfield in field:
  26. html.append('<li>%s</li>' % (subfield()))
  27. html.append('</%s>' % self.html_tag)
  28. return HTMLString(''.join(html))
  29. class MultiCheckboxField(SelectMultipleField):
  30. """
  31. A multiple-select, except displays a list of checkboxes.
  32. Iterating the field will produce subfields, allowing custom rendering of
  33. the enclosed checkbox fields.
  34. """
  35. widget = ListWidget(prefix_label=False)
  36. option_widget = CheckboxInput()
  37. class MyFormField(FormField):
  38. @property
  39. def flattened_errors(self):
  40. return list(itertools.chain.from_iterable(self.errors.values()))
  41. class GeoJSONField(TextField):
  42. widget = TextArea()
  43. def process_formdata(self, valuelist):
  44. if valuelist and valuelist[0]:
  45. max_size = current_app.config['ISP_FORM_GEOJSON_MAX_SIZE']
  46. if len(valuelist[0]) > max_size:
  47. raise ValueError(_(u'JSON value too big, must be less than %(max_size)s',
  48. max_size=filesize_fmt(max_size)))
  49. try:
  50. self.data = json.loads(valuelist[0], object_pairs_hook=collections.OrderedDict)
  51. except Exception as e:
  52. raise ValueError(_(u'Not a valid JSON value'))
  53. elif valuelist and valuelist[0].strip() == '':
  54. self.data = None # if an empty string was passed, set data as None
  55. def _value(self):
  56. if self.raw_data:
  57. return self.raw_data[0]
  58. elif self.data is not None:
  59. return json.dumps(self.data)
  60. else:
  61. return ''
  62. def pre_validate(self, form):
  63. if self.data is not None:
  64. if not validate_geojson(self.data):
  65. raise StopValidation(_(u'Invalid GeoJSON, please check it'))
  66. if not check_geojson_spatialite(self.data):
  67. current_app.logger.error('Spatialite could not decode the following GeoJSON: %s', self.data)
  68. raise StopValidation(_(u'Unable to store GeoJSON in database'))
  69. class Unique(object):
  70. """ validator that checks field uniqueness """
  71. def __init__(self, model, field, message=None, allow_edit=False):
  72. self.model = model
  73. self.field = field
  74. if not message:
  75. message = _(u'this element already exists')
  76. self.message = message
  77. def __call__(self, form, field):
  78. default = field.default() if callable(field.default) else field.default
  79. if field.object_data != default and field.object_data == field.data:
  80. return
  81. check = self.model.query.filter(self.field == field.data).first()
  82. if check:
  83. raise ValidationError(self.message)
  84. TECHNOLOGIES_CHOICES = (
  85. ('ftth', _('FTTH')),
  86. ('fttb', _('FTTB')),
  87. ('dsl', _('DSL')),
  88. ('cube', _('Internet Cube')),
  89. ('wifi', _('Wi-Fi')),
  90. ('vpn', _('VPN')),
  91. )
  92. class CoveredArea(InsecureForm):
  93. name = TextField(_(u'name'), widget=partial(TextInput(), class_='input-medium', placeholder=_(u'Area')))
  94. technologies = SelectMultipleField(_(u'technologies'), choices=TECHNOLOGIES_CHOICES,
  95. widget=partial(Select(True), **{'class': 'selectpicker', 'data-title': _(u'Technologies deployed')}))
  96. area = GeoJSONField(_('area'), widget=partial(TextArea(), class_='geoinput'))
  97. def validate(self, *args, **kwargs):
  98. r = super(CoveredArea, self).validate(*args, **kwargs)
  99. if bool(self.name.data) != bool(self.technologies.data):
  100. self._fields['name'].errors += [_(u'You must fill both fields')]
  101. r = False
  102. return r
  103. class OtherWebsites(InsecureForm):
  104. name = TextField(_(u'name'), widget=partial(TextInput(), class_='input-small', placeholder=_(u'Name')))
  105. url = TextField(_(u'url'), widget=partial(TextInput(), class_='input-medium', placeholder=_(u'URL')),
  106. validators=[Optional(), URL(require_tld=True)])
  107. def generate_choices(choices):
  108. return [(k, LazyProxy(lambda k, s: u'%u - %s' % (k, s), k, choices[k], enable_cache=False))
  109. for k in choices]
  110. class ProjectForm(Form):
  111. name = TextField(_(u'full name'),
  112. description=[_(u'E.g. French Data Network')],
  113. validators=[DataRequired(), Length(min=2),
  114. Unique(ISP, ISP.name)])
  115. shortname = TextField(_(u'short name'),
  116. description=[_(u'E.g. FDN')],
  117. validators=[Optional(), Length(min=2, max=15), Unique(ISP, ISP.shortname)])
  118. description = TextField(_(u'description'),
  119. description=[None, _(u'Short text describing the project')])
  120. logo_url = TextField(_(u'logo url'),
  121. validators=[Optional(), URL(require_tld=True)])
  122. website = TextField(_(u'website'),
  123. validators=[Optional(), URL(require_tld=True)])
  124. other_websites = FieldList(MyFormField(OtherWebsites,
  125. widget=partial(InputListWidget(), class_='formfield')),
  126. min_entries=1, widget=InputListWidget(),
  127. description=[None, _(u'Additional websites that you host (e.g. wiki, etherpad...)')])
  128. contact_email = TextField(_(u'contact email'), validators=[Optional(), Email()],
  129. description=[None, _(u'General contact email address')])
  130. main_ml = TextField(_(u'main mailing list'), validators=[Optional(), Email()],
  131. description=[None, _(u'Address of your main mailing list')])
  132. creation_date = DateField(_(u'creation date'),
  133. validators=[Optional()],
  134. widget=partial(TextInput(), placeholder=_(u'YYYY-mm-dd')),
  135. description=[None, _(u'Date at which the legal structure for your project was created')])
  136. chatrooms = FieldList(TextField(_(u'chatrooms')), min_entries=1, widget=InputListWidget(),
  137. description=[None, _(u'In URI form, e.g. <code>irc://irc.isp.net/#isp</code> or ' +
  138. '<code>xmpp:isp@chat.isp.net?join</code>')])
  139. covered_areas = FieldList(MyFormField(CoveredArea, _('Covered Areas'),
  140. widget=partial(InputListWidget(), class_='formfield')),
  141. min_entries=1, widget=InputListWidget(),
  142. description=[None, _(u'Descriptive name of the covered areas and technologies deployed')])
  143. latitude = DecimalField(_(u'latitude'),
  144. validators=[Optional(), NumberRange(min=-90, max=90)],
  145. description=[None, _(u'Coordinates of your registered office or usual meeting location. '
  146. '<strong>Required in order to appear on the map.</strong>')])
  147. longitude = DecimalField(_(u'longitude'),
  148. validators=[Optional(), NumberRange(min=-180, max=180)])
  149. step = SelectField(_(u'progress step'),
  150. choices=generate_choices(STEPS), coerce=int)
  151. member_count = IntegerField(_(u'members'),
  152. validators=[Optional(), NumberRange(min=0)],
  153. description=[None, _(u'Number of members')])
  154. tech_email = TextField(_('Email'),
  155. validators=[Email(), DataRequired()], description=[None,
  156. _('Technical contact, in case of problems with your submission')])
  157. ########################################
  158. # Fields introduced by ISP format V0.2 #
  159. ########################################
  160. asn = IntegerField(_(u'asn'), validators=[Optional(), NumberRange(min=0)],
  161. description=[None, _('Autonomous System (AS) number.')])
  162. arcep_code = TextField(_(u'arcep code'),
  163. validators=[Optional()], description=[None,
  164. _(u'<a href="https://en.wikipedia.org/wiki/Autorit%%C3%%A9_de_R%%C3%%A9gulation_des_Communications_%%C3%%89lectroniques_et_des_Postes">Arcep</a> identifier')])
  165. #description=[None, _(u'The identifier assigned by the ARCEP')])
  166. xdsl = IntegerField(_(u'xdsl subscribers'),
  167. validators=[Optional(), NumberRange(min=0)],
  168. description=[None, _(u'Number of xdsl subscribers')])
  169. vpn = IntegerField(_(u'vpn subscribers'),
  170. validators=[Optional(), NumberRange(min=0)],
  171. description=[None, _(u'Number of vpn subscribers')])
  172. wifi = IntegerField(_(u'wifi subscribers'),
  173. validators=[Optional(), NumberRange(min=0)],
  174. description=[None, _(u'Number of wifi subscribers')])
  175. fiber = IntegerField(_(u'fiber subscribers'),
  176. validators=[Optional(), NumberRange(min=0)],
  177. description=[None, _(u'Number of fiber subscribers')])
  178. statutes = TextField(_(u'statutes url',
  179. validators=[Optional(), URL(require_tld=True)],
  180. description=[None, _(u'URL pointing to the organisation statutes')]))
  181. internal_rules = TextField(_(u'internal rules url',
  182. validators=[Optional(), URL(require_tld=True)],
  183. description=[_(u'URL pointing to the organisation internal rules'), _(u'URL pointing to the organisation internal rules')]))
  184. internet_cube = BooleanField(_(u'internet cube'),
  185. validators=[Optional()],
  186. description=[None, _(u'Participating to the internetcube project (collective purchases, install parties)')])
  187. nlnog_participant = BooleanField(_(u'nlnog participant'),
  188. validators=[Optional()],
  189. description=[None, _(u'Participating to the NL-NOG RING? See <code>https://ring.nlnog.net/</code>')])
  190. ipv6_servers = SelectField(_(u'ipv6 servers'),
  191. choices=generate_choices(IPV6_SUPPORT),
  192. coerce=int,
  193. validators=[Optional()],
  194. description=[None, _(u'Servers can be reached in IPV6?')])
  195. ipv6_subscribers = SelectField(_(u'ipv6 subscribers'),
  196. choices=generate_choices(IPV6_SUPPORT), validators=[Optional()],
  197. coerce=int,
  198. description=[None, _(u'Subscribers are provided with IPV6 connectivity?')])
  199. def validate(self, *args, **kwargs):
  200. r = super(ProjectForm, self).validate(*args, **kwargs)
  201. if (self.latitude.data is None) != (self.longitude.data is None):
  202. self._fields['longitude'].errors += [_(u'You must fill both fields')]
  203. r = False
  204. return r
  205. def validate_covered_areas(self, field):
  206. if len(filter(lambda e: e['name'], field.data)) == 0:
  207. # not printed, whatever..
  208. raise ValidationError(_(u'You must specify at least one area'))
  209. geojson_size = sum([len(ca.area.raw_data[0]) for ca in self.covered_areas if ca.area.raw_data])
  210. max_size = current_app.config['ISP_FORM_GEOJSON_MAX_SIZE_TOTAL']
  211. if geojson_size > max_size:
  212. # TODO: XXX This is not printed !
  213. raise ValidationError(gettext(u'The size of all GeoJSON data combined must not exceed %(max_size)s',
  214. max_size=filesize_fmt(max_size)))
  215. def to_json(self, json=None):
  216. if json is None:
  217. json = {}
  218. json['name'] = self.name.data
  219. def optobj(k, d):
  220. filtered_none = {k: v for k,v in d.iteritems() if v != None}
  221. if filtered_none != {}:
  222. json[k] = filtered_none
  223. def optstr(k, v):
  224. if k in json or v:
  225. json[k] = v
  226. def optlist(k, v):
  227. if k in json or len(v):
  228. json[k] = v
  229. def transform_covered_areas(cas):
  230. for ca in cas:
  231. if not ca['name']:
  232. continue
  233. if 'area' in ca and ca['area'] is None:
  234. del ca['area']
  235. yield ca
  236. optstr('shortname', self.shortname.data)
  237. optstr('description', self.description.data)
  238. optstr('logoURL', self.logo_url.data)
  239. optstr('website', self.website.data)
  240. optstr('otherWebsites', dict(((w['name'], w['url']) for w in self.other_websites.data if w['name'])))
  241. optstr('email', self.contact_email.data)
  242. optstr('mainMailingList', self.main_ml.data)
  243. optstr('creationDate', self.creation_date.data.isoformat() if self.creation_date.data else None)
  244. optstr('progressStatus', self.step.data)
  245. optstr('memberCount', self.member_count.data)
  246. optlist('chatrooms', filter(bool, self.chatrooms.data)) # remove empty strings
  247. optstr('coordinates', {'latitude': self.latitude.data, 'longitude': self.longitude.data}
  248. if self.latitude.data else {})
  249. optlist('coveredAreas', list(transform_covered_areas(self.covered_areas.data)))
  250. optstr('asn', self.asn.data)
  251. optstr('arcepCode', self.arcep_code.data)
  252. optstr('internetcube', self.internet_cube.data)
  253. optstr('nlnogParticipant', self.nlnog_participant.data)
  254. optobj('publicDocuments', {
  255. 'statutes': self.statutes.data,
  256. 'internalRules': self.internal_rules.data})
  257. optobj('subscriberCount', {
  258. 'xdsl': self.xdsl.data,
  259. 'vpn': self.vpn.data,
  260. 'wifi': self.wifi.data,
  261. 'fiber': self.fiber.data})
  262. optobj('ipv6Support', {
  263. 'infra': self.ipv6_servers.data,
  264. 'subscribers': self.ipv6_subscribers.data})
  265. return json
  266. @classmethod
  267. def edit_json(cls, isp):
  268. json = isp.json
  269. obj = type('abject', (object,), {})()
  270. def set_attr(attr, itemk=None, d=json):
  271. if itemk is None:
  272. itemk = attr
  273. if itemk in d:
  274. setattr(obj, attr, d[itemk])
  275. set_attr('name')
  276. set_attr('shortname')
  277. set_attr('description')
  278. set_attr('logo_url', 'logoURL')
  279. set_attr('website')
  280. set_attr('contact_email', 'email')
  281. set_attr('main_ml', 'mainMailingList')
  282. set_attr('creation_date', 'creationDate')
  283. if hasattr(obj, 'creation_date'):
  284. obj.creation_date = ISP.str2date(obj.creation_date)
  285. set_attr('step', 'progressStatus')
  286. set_attr('member_count', 'memberCount')
  287. set_attr('subscriber_count', 'subscriberCount')
  288. set_attr('chatrooms', 'chatrooms')
  289. if 'coordinates' in json:
  290. set_attr('latitude', d=json['coordinates'])
  291. set_attr('longitude', d=json['coordinates'])
  292. if 'otherWebsites' in json:
  293. setattr(obj, 'other_websites', [{'name': n, 'url': w} for n, w in json['otherWebsites'].iteritems()])
  294. set_attr('covered_areas', 'coveredAreas')
  295. # V0.2
  296. set_attr('asn')
  297. set_attr('arcep_code', 'arcepCode')
  298. set_attr('internet_cube', 'internetcube')
  299. set_attr('nlnog_participant', 'nlnogParticipant')
  300. # TODO: refactor that. I don't know python enough to find it but I'm
  301. # sure there's a clever trick to make set_attr support nested structures.
  302. if 'publicDocuments' in json:
  303. pubd = json['publicDocuments']
  304. set_attr('statutes', 'statutes', pubd)
  305. set_attr('internal_rules', 'internalRules', pubd)
  306. if 'ipv6Support' in json:
  307. ipd = json['ipv6Support']
  308. set_attr('ipv6_servers', 'infra', ipd)
  309. set_attr('ipv6_sibscribers', 'subscribers', ipd)
  310. if 'subscriberCount' in json:
  311. scd = json['subscriberCount']
  312. set_attr('xdsl', None, scd)
  313. set_attr('vpn', None, scd)
  314. set_attr('wifi', None, scd)
  315. set_attr('fiber', None, scd)
  316. obj.tech_email = isp.tech_email
  317. return cls(obj=obj)
  318. class URLField(TextField):
  319. def _value(self):
  320. if isinstance(self.data, basestring):
  321. return self.data
  322. elif self.data is None:
  323. return ''
  324. else:
  325. return urlparse.urlunsplit(self.data)
  326. def process_formdata(self, valuelist):
  327. if valuelist:
  328. try:
  329. self.data = urlparse.urlsplit(valuelist[0])
  330. except:
  331. self.data = None
  332. raise ValidationError(_(u'Invalid URL'))
  333. def is_url_unique(url):
  334. if isinstance(url, basestring):
  335. url = urlparse.urlsplit(url)
  336. t = list(url)
  337. t[2] = ''
  338. u1 = urlparse.urlunsplit(t)
  339. t[0] = 'http' if t[0] == 'https' else 'https'
  340. u2 = urlparse.urlunsplit(t)
  341. if ISP.query.filter(ISP.json_url.startswith(u1) | ISP.json_url.startswith(u2)).count() > 0:
  342. return False
  343. return True
  344. class ProjectJSONForm(Form):
  345. json_url = URLField(_(u'base url'), description=[_(u'E.g. https://isp.com/'),
  346. _(u'A ressource implementing our JSON-Schema specification ' +
  347. 'must exist at path /isp.json')])
  348. tech_email = TextField(_(u'Email'), validators=[Email()], description=[None,
  349. _(u'Technical contact, in case of problems')])
  350. def validate_json_url(self, field):
  351. if not field.data.netloc:
  352. raise ValidationError(_(u'Invalid URL'))
  353. if field.data.scheme not in ('http', 'https'):
  354. raise ValidationError(_(u'Invalid URL (must be HTTP(S))'))
  355. if not field.object_data and not is_url_unique(field.data):
  356. raise ValidationError(_(u'This URL is already in our database'))
  357. class RequestEditToken(Form):
  358. tech_email = TextField(_(u'Tech Email'), validators=[Email()], description=[None,
  359. _(u'The Technical contact you provided while registering')])