Repository: carpentries/amy

Issue:
Bugs with "Import/Update from URL"
1. "Import from URL" has wrong `country` field ID in JavaScript
2. Something's wrong with for example this event: https://amy.software-carpentry.org/workshops/event/2015-02-09-ucdavis (it uses remote that redirects to somewhere else, plus when we use the remote after redirection we get some errors). The officially saved URL is https://datacarpentry.github.io/2015-02-09-ucdavis/
3. Fix spaces around URLs



Discussion:
Thanks for this - I was just about to put in an issue to strip leading/trailing whitespace around urls.


Resolving patch:
diff --git a/workshops/check.py b/workshops/check.py
deleted file mode 100755
--- a/workshops/check.py
+++ /dev/null
@@ -1,431 +0,0 @@
-#!/usr/bin/env python
-
-'''Check that index.html is valid and print out warnings and errors
-when the header is malformed.  See the docstrings on the checking
-functions for a summary of the checks.
-'''
-
-from __future__ import print_function
-import sys
-import os
-import re
-import logging
-import yaml
-from collections import Counter
-
-try:  # Hack to make codebase compatible with python 2 and 3
-    our_basestring = basestring
-except NameError:
-    our_basestring = str
-
-__version__ = '0.6'
-
-
-# basic logging configuration
-logger = logging.getLogger(__name__)
-verbosity = logging.INFO  # severity of at least INFO will emerge
-logger.setLevel(verbosity)
-
-# create console handler and set level to debug
-console_handler = logging.StreamHandler()
-console_handler.setLevel(verbosity)
-
-formatter = logging.Formatter('%(levelname)s: %(message)s')
-console_handler.setFormatter(formatter)
-logger.addHandler(console_handler)
-
-
-# TODO: these regexp patterns need comments inside
-EMAIL_PATTERN = r'[^@]+@[^@]+\.[^@]+'
-HUMANTIME_PATTERN = r'((0?[1-9]|1[0-2]):[0-5]\d(am|pm)(-|to)(0?[1-9]|1[0-2]):[0-5]\d(am|pm))|((0?\d|1\d|2[0-3]):[0-5]\d(-|to)(0?\d|1\d|2[0-3]):[0-5]\d)'
-EVENTBRITE_PATTERN = r'\d{9,10}'
-URL_PATTERN = r'https?://.+'
-
-DEFAULT_CONTACT_EMAIL = 'admin@software-carpentry.org'
-
-USAGE = 'Usage: "python check.py" or "python check.py path/to/index.html"\n'
-
-# Country and language codes.  Note that codes mean different things: 'ar'
-# is 'Arabic' as a language but 'Argentina' as a country.
-
-ISO_COUNTRY = [
-    'ad', 'ae', 'af', 'ag', 'ai', 'al', 'am', 'an', 'ao', 'aq', 'ar', 'as',
-    'at', 'au', 'aw', 'ax', 'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh',
-    'bi', 'bj', 'bm', 'bn', 'bo', 'br', 'bs', 'bt', 'bv', 'bw', 'by', 'bz',
-    'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co',
-    'cr', 'cu', 'cv', 'cx', 'cy', 'cz', 'de', 'dj', 'dk', 'dm', 'do', 'dz',
-    'ec', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm',
-    'fo', 'fr', 'ga', 'gb', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm',
-    'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn',
-    'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir', 'is',
-    'it', 'je', 'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn', 'kp',
-    'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 'ls', 'lt',
-    'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'me', 'mg', 'mh', 'mk', 'ml', 'mm',
-    'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my',
-    'mz', 'na', 'nc', 'ne', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu',
-    'nz', 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr',
-    'ps', 'pt', 'pw', 'py', 'qa', 're', 'ro', 'rs', 'ru', 'rw', 'sa', 'sb',
-    'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so',
-    'sr', 'st', 'sv', 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 'tk',
-    'tl', 'tm', 'tn', 'to', 'tr', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'um',
-    'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws',
-    'ye', 'yt', 'za', 'zm', 'zw'
-]
-
-ISO_LANGUAGE = [
-    'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az',
-    'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs', 'ca', 'ce',
-    'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', 'da', 'de', 'dv', 'dz', 'ee',
-    'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'ff', 'fi', 'fj', 'fo', 'fr',
-    'fy', 'ga', 'gd', 'gl', 'gn', 'gu', 'gv', 'ha', 'he', 'hi', 'ho', 'hr',
-    'ht', 'hu', 'hy', 'hz', 'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'io', 'is',
-    'it', 'iu', 'ja', 'jv', 'ka', 'kg', 'ki', 'kj', 'kk', 'kl', 'km', 'kn',
-    'ko', 'kr', 'ks', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lg', 'li', 'ln',
-    'lo', 'lt', 'lu', 'lv', 'mg', 'mh', 'mi', 'mk', 'ml', 'mn', 'mr', 'ms',
-    'mt', 'my', 'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no', 'nr', 'nv',
-    'ny', 'oc', 'oj', 'om', 'or', 'os', 'pa', 'pi', 'pl', 'ps', 'pt', 'qu',
-    'rm', 'rn', 'ro', 'ru', 'rw', 'sa', 'sc', 'sd', 'se', 'sg', 'si', 'sk',
-    'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta',
-    'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw',
-    'ty', 'ug', 'uk', 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi',
-    'yo', 'za', 'zh', 'zu'
-]
-
-
-def add_error(msg, errors):
-    """Add error to the list of errors."""
-    errors.append(msg)
-
-
-def add_suberror(msg, errors):
-    """Add sub error, ie. error indented by 1 level ("\t"), to the list of errors."""
-    errors.append("\t{0}".format(msg))
-
-
-def look_for_fixme(func):
-    '''Decorator to fail test if text argument starts with "FIXME".'''
-    def inner(arg):
-        if (arg is not None) and \
-           isinstance(arg, our_basestring) and \
-           arg.lstrip().startswith('FIXME'):
-            return False
-        return func(arg)
-    return inner
-
-
-@look_for_fixme
-def check_layout(layout):
-    '''"layout" in YAML header must be "workshop".'''
-
-    return layout == 'workshop'
-
-
-@look_for_fixme
-def check_root(root):
-    '''"root" (the path from this page to the root directory) must be "."'''
-
-    return root == '.'
-
-
-@look_for_fixme
-def check_country(country):
-    '''"country" must be a lowercase ISO-3166 two-letter code.'''
-
-    return country in ISO_COUNTRY
-
-
-@look_for_fixme
-def check_language(language):
-    '''"language" must be a lowercase ISO-639 two-letter code.'''
-
-    return language in ISO_LANGUAGE
-
-
-@look_for_fixme
-def check_humandate(date):
-    '''"humandate" must be a human-readable date with a 3-letter month and
-    4-digit year.  Examples include "Feb 18-20, 2025" and "Feb 18 and
-    20, 2025".  It may be in languages other than English, but the
-    month name should be kept short to aid formatting of the main
-    Software Carpentry web site.'''
-
-    if "," not in date:
-        return False
-
-    month_dates, year = date.split(",")
-
-    # The first three characters of month_dates are not empty
-    month = month_dates[:3]
-    if any(char == " " for char in month):
-        return False
-
-    # But the fourth character is empty ("February" is illegal)
-    if month_dates[3] != " ":
-        return False
-
-    # year contains *only* numbers
-    try:
-        int(year)
-    except:
-        return False
-
-    return True
-
-
-@look_for_fixme
-def check_humantime(time):
-    '''"humantime" is a human-readable start and end time for the workshop,
-    such as "09:00 - 16:00".'''
-
-    return bool(re.match(HUMANTIME_PATTERN, time.replace(" ", "")))
-
-
-def check_date(this_date):
-    '''"startdate" and "enddate" are machine-readable start and end dates for
-    the workshop, and must be in YYYY-MM-DD format, e.g., "2015-07-01".'''
-
-    from datetime import date
-    # yaml automatically loads valid dates as datetime.date
-    return isinstance(this_date, date)
-
-
-@look_for_fixme
-def check_latitude_longitude(latlng):
-    '''"latlng" must be a valid latitude and longitude represented as two
-    floating-point numbers separated by a comma.'''
-
-    try:
-        lat, lng = latlng.split(',')
-        lat = float(lat)
-        long = float(lng)
-    except ValueError:
-        return False
-    return (-90.0 <= lat <= 90.0) and (-180.0 <= long <= 180.0)
-
-
-def check_instructors(instructors):
-    '''"instructor" must be a non-empty comma-separated list of quoted names,
-    e.g. ['First name', 'Second name', ...'].  Do not use "TBD" or other
-    placeholders.'''
-
-    # yaml automatically loads list-like strings as lists
-    return isinstance(instructors, list) and len(instructors) > 0
-
-
-def check_helpers(helpers):
-    '''"helper" must be a comma-separated list of quoted names,
-    e.g. ['First name', 'Second name', ...'].  The list may be empty.  Do
-    not use "TBD" or other placeholders.'''
-
-    # yaml automatically loads list-like strings as lists
-    return isinstance(helpers, list) and len(helpers) >= 0
-
-
-@look_for_fixme
-def check_email(email):
-    '''"contact" must be a valid email address consisting of characters, a
-    @, and more characters.  It should not be the default contact
-    email address "admin@software-carpentry.org".'''
-
-    return bool(re.match(EMAIL_PATTERN, email)) and \
-           (email != DEFAULT_CONTACT_EMAIL)
-
-
-def check_eventbrite(eventbrite):
-    '''"eventbrite" (the Eventbrite registration key) must be 9 or more digits.'''
-
-    if isinstance(eventbrite, int):
-        return True
-    else:
-        return bool(re.match(EVENTBRITE_PATTERN, eventbrite))
-
-
-@look_for_fixme
-def check_etherpad(etherpad):
-    '''"etherpad" must be a valid URL.'''
-
-    return bool(re.match(URL_PATTERN, etherpad))
-
-
-@look_for_fixme
-def check_pass(value):
-    '''This test always passes (it is used for "checking" things like
-    addresses, for which no sensible validation is feasible).'''
-
-    return True
-
-
-HANDLERS = {
-    'layout':     (True, check_layout, 'layout isn\'t "workshop"'),
-    'root':       (True, check_root, 'root can only be "."'),
-
-    'country':    (True, check_country,
-                   'country invalid: must use lowercase two-letter ISO code ' +
-                   'from ' + ', '.join(ISO_COUNTRY)),
-    'language':   (False,  check_language,
-                   'language invalid: must use lowercase two-letter ISO code' +
-                   ' from ' + ', '.join(ISO_LANGUAGE)),
-
-    'humandate':  (True, check_humandate,
-                   'humandate invalid. Please use three-letter months like ' +
-                   '"Jan" and four-letter years like "2025".'),
-    'humantime':  (True, check_humantime,
-                   'humantime doesn\'t include numbers'),
-    'startdate':  (True, check_date,
-                   'startdate invalid. Must be of format year-month-day, ' +
-                   'i.e., 2014-01-31.'),
-    'enddate':    (False, check_date,
-                   'enddate invalid. Must be of format year-month-day, i.e.,' +
-                   ' 2014-01-31.'),
-
-    'latlng':     (True, check_latitude_longitude,
-                   'latlng invalid. Check that it is two floating point ' +
-                   'numbers, separated by a comma.'),
-
-    'instructor': (True, check_instructors,
-                   'instructor list isn\'t a valid list of format ' +
-                   '["First instructor", "Second instructor",..].'),
-    'helper':     (True, check_helpers,
-                   'helper list isn\'t a valid list of format ' +
-                   '["First helper", "Second helper",..].'),
-
-    'contact':    (True, check_email,
-                   'contact email invalid or still set to ' +
-                   '"{0}".'.format(DEFAULT_CONTACT_EMAIL)),
-
-    'eventbrite': (False, check_eventbrite, 'Eventbrite key appears invalid.'),
-    'etherpad':   (False, check_etherpad, 'Etherpad URL appears invalid.'),
-
-    'venue':      (False, check_pass, 'venue name not specified'),
-    'address':    (False, check_pass, 'address not specified')
-}
-
-# REQUIRED is all required categories.
-REQUIRED = set([k for k in HANDLERS if HANDLERS[k][0]])
-
-# OPTIONAL is all optional categories.
-OPTIONAL = set([k for k in HANDLERS if not HANDLERS[k][0]])
-
-
-def check_validity(data, function, errors, error_msg):
-    '''Wrapper-function around the various check-functions.'''
-    valid = function(data)
-    if not valid:
-        add_error(error_msg, errors)
-        add_suberror('Offending entry is: "{0}"'.format(data), errors)
-    return valid
-
-
-def check_blank_lines(raw_data, errors, error_msg):
-    '''Blank lines are not allowed in category headers.'''
-    lines = [x.strip() for x in raw_data.split('\n')]
-    if '' in lines:
-        add_error(error_msg, errors)
-        add_suberror('{0} blank lines found in header'.format(lines.count('')), errors)
-        return False
-    return True
-
-
-def check_categories(left, right, errors, error_msg):
-    '''Report set difference of categories.'''
-    result = left - right
-    if result:
-        add_error(error_msg, errors)
-        add_suberror('Offending entries: {0}'.format(result), errors)
-        return False
-    return True
-
-
-def get_header(text):
-    '''Extract YAML header from raw data, returning (None, None) if no
-    valid header found and (raw, parsed) if header found.'''
-
-    # YAML header must be right at the start of the file.
-    if not text.startswith('---'):
-        return None, None
-
-    # YAML header must start and end with '---'
-    pieces = text.split('---')
-    if len(pieces) < 3:
-        return None, None
-
-    # Return raw text and YAML-ized form.
-    raw = pieces[1].strip()
-    return raw, yaml.load(raw)
-
-
-def check_file(filename, data):
-    '''Get header from index.html, call all other functions and check file
-    for validity. Return list of errors (empty when no errors).'''
-
-    errors = []
-    raw, header = get_header(data)
-    if header is None:
-        msg = ('Cannot find YAML header in given file "{0}".'.format(filename))
-        add_error(msg, errors)
-        return errors
-
-    # Do we have any blank lines in the header?
-    is_valid = check_blank_lines(raw, errors,
-                                 'There are blank lines in the header')
-
-    # Look through all header entries.  If the category is in the input
-    # file and is either required or we have actual data (as opposed to
-    # a commented-out entry), we check it.  If it *isn't* in the header
-    # but is required, report an error.
-    for category in HANDLERS:
-        required, handler_function, error_message = HANDLERS[category]
-        if category in header:
-            if required or header[category]:
-                is_valid &= check_validity(header[category],
-                                           handler_function, errors,
-                                           error_message)
-        elif required:
-            msg = 'index file is missing mandatory key "{0}"'.format(category)
-            add_error(msg, errors)
-            is_valid = False
-
-    # Check whether we have missing or too many categories
-    seen_categories = set(header.keys())
-
-    is_valid &= check_categories(REQUIRED, seen_categories, errors,
-                                 'There are missing categories')
-
-    is_valid &= check_categories(seen_categories, REQUIRED.union(OPTIONAL),
-                                 errors, 'There are superfluous categories')
-
-    return errors
-
-
-def main():
-    '''Run as the main program.'''
-    filename = None
-    if len(sys.argv) == 1:
-        if os.path.exists('./index.html'):
-            filename = './index.html'
-        elif os.path.exists('../index.html'):
-            filename = '../index.html'
-    elif len(sys.argv) == 2:
-        filename = sys.argv[1]
-
-    if filename is None:
-        print(USAGE, file=sys.stderr)
-        sys.exit(1)
-
-    logger.info('Testing "{0}"'.format(filename))
-
-    with open(filename) as reader:
-        data = reader.read()
-        errors = check_file(filename, data)
-
-    if errors:
-        for m in errors:
-            logger.error(m)
-        sys.exit(1)
-    else:
-        logger.info('Everything seems to be in order')
-        sys.exit(0)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/workshops/util.py b/workshops/util.py
--- a/workshops/util.py
+++ b/workshops/util.py
@@ -1,16 +1,16 @@
 # coding: utf-8
+from collections import namedtuple
 import csv
+import datetime
 from math import pi, sin, cos, acos
 import re
+import yaml
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import IntegrityError, transaction
 from django.db.models import Q
 from django.core.paginator import Paginator as DjangoPaginator
-from django_countries import countries
-import requests
 
-from workshops.check import get_header
 from workshops.models import Event, Role, Person, Task, Award
 
 
@@ -363,102 +363,182 @@ def merge_persons(person_from, person_to):
     person_from.delete()
 
 
-class WrongEventURL(Exception):
-    pass
+def generate_url_to_event_index(website_url):
+    """Given URL to workshop's website, generate a URL to its raw `index.html`
+    file in GitHub repository."""
+    template = ('https://raw.githubusercontent.com/{name}/{repo}'
+                '/gh-pages/index.html')
+    regex = Event.WEBSITE_REGEX
 
+    results = regex.match(website_url)
+    if results:
+        return template.format(**results.groupdict())
+    return None
 
-def normalize_event_index_url(url):
-    """From any event URL, make one URL to the raw content.
+ALLOWED_TAG_NAMES = [
+    'slug', 'startdate', 'enddate', 'country', 'venue', 'address',
+    'latlng', 'language', 'eventbrite', 'instructor', 'helper', 'contact',
+]
 
-    For example:
 
-    * http://user.github.io/SLUG/
-    * http://user.github.io/SLUG/index.html
-    * https://github.com/user/SLUG/
-    * https://github.com/user/SLUG/blob/gh-pages/index.html
-    * https://raw.githubusercontent.com/user/SLUG/gh-pages/index.html
+def find_tags_on_event_index(content):
+    """Given workshop's raw `index.html`, find and take YAML tags that
+    have workshop-related data."""
+    try:
+        first, header, last = content.split('---')
+        tags = yaml.load(header.strip())
 
-    …will become:
-    https://raw.githubusercontent.com/user/SLUG/gh-pages/index.html
-    """
-    template = ('https://raw.githubusercontent.com/{name}/{repo}'
-                '/gh-pages/index.html')
-    formats = [
-        r'https?://(?P<name>[^\.]+)\.github\.(io|com)/(?P<repo>[^/]+)/?',
-        (r'https?://(?P<name>[^\.]+)\.github\.(io|com)/(?P<repo>[^/]+)/'
-         r'index\.html'),
-        r'https://github\.com/(?P<name>[^/]+)/(?P<repo>[^/]+)/?',
-        (r'https://github\.com/(?P<name>[^/]+)/(?P<repo>[^/]+)/'
-         r'blob/gh-pages/index\.html'),
-        (r'https://raw.githubusercontent.com/(?P<name>[^/]+)/(?P<repo>[^/]+)'
-         r'/gh-pages/index.html'),
-    ]
-    for format in formats:
-        results = re.match(format, url)
-        if results:
-            repo = results.groupdict()['repo']
-            return template.format(**results.groupdict()), repo
+        # get tags to the form returned by `find_tags_on_event_website`
+        # because YAML tries to interpret values from index's header
+        filtered_tags = {key: value for key, value in tags.items()
+                         if key in ALLOWED_TAG_NAMES}
+        for key, value in filtered_tags.items():
+            if isinstance(value, int):
+                filtered_tags[key] = str(value)
+            elif isinstance(value, datetime.date):
+                filtered_tags[key] = '{:%Y-%m-%d}'.format(value)
+            elif isinstance(value, list):
+                filtered_tags[key] = ', '.join(value)
+
+        return filtered_tags
+
+    except (ValueError, yaml.scanner.ScannerError):
+        # can't unpack or header is not YML format
+        return dict()
 
-    raise WrongEventURL("This event URL is incorrect: {0}".format(url))
 
+def find_tags_on_event_website(content):
+    """Given website content, find and take <meta> tags that have
+    workshop-related data."""
 
-def parse_tags_from_event_index(orig_url):
-    url, slug = normalize_event_index_url(orig_url)
-    response = requests.get(url)
+    R = r'<meta name="(?P<name>[\w-]+)" content="(?P<content>.+)" />$'
+    regexp = re.compile(R, re.M)
 
-    # will throw requests.exceptions.HTTPError if status is not OK
-    response.raise_for_status()
+    return {name: content for name, content in regexp.findall(content)
+            if name in ALLOWED_TAG_NAMES}
 
-    _, headers = get_header(response.text)
+
+def parse_tags_from_event_website(tags):
+    """Simple preprocessing of the tags from event website."""
+    # no compatibility with old-style names
+    country = tags.get('country', '').upper()[0:2]
+    if len(country) < 2:
+        country = ''
+    language = tags.get('language', '').upper()[0:2]
+    if len(language) < 2:
+        language = ''
 
     try:
-        latitude, longitude = headers.get('latlng', '').split(',')
-        latitude = latitude.strip()
-        longitude = longitude.strip()
+        latitude, _ = tags.get('latlng', '').split(',')
+        latitude = float(latitude.strip())
     except ValueError:
-        latitude, longitude = '', ''
-
-    country = headers.get('country', '')
-    if len(country) == 2:
-        # special case: we're asking workshops to have country as a 2-letter
-        # code, but for now most of them use (semi-)full country names
-        country = country.upper()
-    elif len(country):
-        # probably a full-length country name
-        countries_codes = {name: code for code, name in countries}
-        country = country.replace('-', ' ')
-        country = countries_codes.get(country, country)
-
-    # put instructors, helpers and venue into notes
-    notes = """INSTRUCTORS: {instructors}
-
-HELPERS: {helpers}
-
-COUNTRY: {country}""".format(
-        country=country,
-        instructors=", ".join(headers.get('instructor') or []),
-        helpers=", ".join(headers.get('helper') or []),
-    )
+        latitude = None
+    try:
+        _, longitude = tags.get('latlng', '').split(',')
+        longitude = float(longitude.strip())
+    except ValueError:
+        longitude = None
+
+    try:
+        reg_key = tags.get('eventbrite', '')
+        reg_key = int(reg_key)
+    except ValueError:
+        reg_key = None
+
+    try:
+        start = tags.get('startdate', '')
+        start = datetime.datetime.strptime(start, '%Y-%m-%d').date()
+    except ValueError:
+        start = None
+
+    try:
+        end = tags.get('enddate', '')
+        end = datetime.datetime.strptime(end, '%Y-%m-%d').date()
+    except ValueError:
+        end = None
+
+    # Split string of comma-separated names into a list, but return empty list
+    # instead of [''] when there are no instructors/helpers.
+    instructors = tags.get('instructor', '').split('|')
+    instructors = [instructor.strip() for instructor in instructors]
+    instructors = [] if not any(instructors) else instructors
+    helpers = tags.get('helper', '').split('|')
+    helpers = [helper.strip() for helper in helpers]
+    helpers = [] if not any(helpers) else helpers
 
     return {
-        'slug': slug,
-        'start': headers.get('startdate', ''),
-        'end': headers.get('enddate', ''),
-        # A neat trick to get website URL easily.  It creates an Event object
-        # (but doesn't add it to the database) and uses it's translating
-        # @property 'website_url's
-        'url': Event(url=orig_url).website_url,
-        'reg_key': headers.get('eventbrite', ''),
-        'contact': headers.get('contact', ''),
-        'notes': notes,
-        'venue': headers.get('venue', ''),
-        'address': headers.get('address', ''),
+        'slug': tags.get('slug', ''),
+        'language': language,
+        'start': start,
+        'end': end,
         'country': country,
+        'venue': tags.get('venue', ''),
+        'address': tags.get('address', ''),
         'latitude': latitude,
         'longitude': longitude,
+        'reg_key': reg_key,
+        'instructors': instructors,
+        'helpers': helpers,
+        'contact': tags.get('contact', ''),
     }
 
 
+def validate_tags_from_event_website(tags):
+    errors = []
+
+    Requirement = namedtuple(
+        'Requirement',
+        ['name', 'display', 'required', 'match_format'],
+    )
+
+    DATE_FMT = r'^\d{4}-\d{2}-\d{2}$'
+    SLUG_FMT = r'^\d{4}-\d{2}-\d{2}-.+$'
+    TWOCHAR_FMT = r'^\w\w$'
+    FRACTION_FMT = r'[-+]?[0-9]*\.?[0-9]*'
+    requirements = [
+        Requirement('slug', 'workshop name', True, SLUG_FMT),
+        Requirement('language', None, False, TWOCHAR_FMT),
+        Requirement('startdate', 'start date', True, DATE_FMT),
+        Requirement('enddate', 'end date', False, DATE_FMT),
+        Requirement('country', None, True, TWOCHAR_FMT),
+        Requirement('venue', None, True, None),
+        Requirement('address', None, True, None),
+        Requirement('latlng', 'latitude / longitude', True,
+                    '^' + FRACTION_FMT + r',\s?' + FRACTION_FMT + '$'),
+        Requirement('instructor', None, True, None),
+        Requirement('helper', None, True, None),
+        Requirement('contact', None, True, None),
+        Requirement('eventbrite', 'Eventbrite event ID', False, r'^\d+$'),
+    ]
+
+    for requirement in requirements:
+        d_ = requirement._asdict()
+        name_ = ('{display} ({name})'.format(**d_)
+                 if requirement.display
+                 else '{name}'.format(**d_))
+        type_ = 'required' if requirement.required else 'optional'
+        value_ = tags.get(requirement.name)
+
+        if not value_:
+            errors.append('Missing {} tag {}.'.format(type_, name_))
+
+        if value_ == 'FIXME':
+            errors.append('Placeholder value "FIXME" for {} tag {}.'
+                          .format(type_, name_))
+        else:
+            try:
+                if not re.match(requirement.match_format, value_):
+                    errors.append(
+                        'Invalid value "{}" for {} tag {}: should be in '
+                        'format "{}".'
+                        .format(value_, type_, name_, requirement.match_format)
+                    )
+            except (re.error, TypeError):
+                pass
+
+    return errors
+
+
 def update_event_attendance_from_tasks(event):
     """Increase event.attendance if there's more learner tasks belonging to the
     event."""
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -50,7 +50,6 @@
     ProfileUpdateRequest,
     TodoItem,
 )
-from workshops.check import check_file
 from workshops.forms import (
     SearchForm, DebriefForm, InstructorsForm, PersonForm, PersonBulkAddForm,
     EventForm, TaskForm, TaskFullForm, bootstrap_helper, bootstrap_helper_get,
@@ -64,8 +63,12 @@
 from workshops.util import (
     upload_person_task_csv,  verify_upload_person_task,
     create_uploaded_persons_tasks, InternalError, Paginator, merge_persons,
-    normalize_event_index_url, WrongEventURL, parse_tags_from_event_index,
-    update_event_attendance_from_tasks
+    update_event_attendance_from_tasks,
+    generate_url_to_event_index,
+    find_tags_on_event_index,
+    find_tags_on_event_website,
+    parse_tags_from_event_website,
+    validate_tags_from_event_website,
 )
 
 from workshops.filters import (
@@ -885,30 +888,56 @@ def event_details(request, event_ident):
 @login_required
 def validate_event(request, event_ident):
     '''Check the event's home page *or* the specified URL (for testing).'''
-    page_url, error_messages = None, []
     event = Event.get_by_ident(event_ident)
-    github_url = request.GET.get('url', None)  # for manual override
-    if github_url is None:
-        github_url = event.repository_url
+
+    page_url = request.GET.get('url', None)  # for manual override
+    if page_url is None:
+        page_url = event.url
+
+    page_url = page_url.strip()
+
+    error_messages = []
 
     try:
-        page_url, _ = normalize_event_index_url(github_url)
+        # fetch page
         response = requests.get(page_url)
+        response.raise_for_status()  # assert it's 200 OK
+        content = response.text
 
-        if response.status_code != 200:
-            error_messages.append('Request for {0} returned status code {1}'
-                                  .format(page_url, response.status_code))
-        else:
-            error_messages = check_file(page_url, response.text)
-    except WrongEventURL:
-        error_messages = ["This is not a proper event URL.", ]
-    except requests.ConnectionError:
-        error_messages = ["Network connection error.", ]
-
-    context = {'title' : 'Validate Event {0}'.format(event),
-               'event' : event,
-               'page' : page_url,
-               'error_messages' : error_messages}
+        # find tags
+        tags = find_tags_on_event_website(content)
+
+        if 'slug' not in tags:
+            # there are no HTML tags, so let's try the old method
+            page_url = generate_url_to_event_index(page_url)
+
+            # fetch page
+            response = requests.get(page_url)
+
+            if response.status_code == 200:
+                # don't throw errors for pages we fall back to
+                content = response.text
+                tags = find_tags_on_event_index(page_url)
+
+        # validate them
+        error_messages = validate_tags_from_event_website(tags)
+
+    except requests.exceptions.HTTPError as e:
+        error_messages.append(
+            'Request for "{0}" returned status code {1}'
+            .format(page_url, e.response.status_code)
+        )
+
+    except (requests.exceptions.ConnectionError,
+            requests.exceptions.Timeout):
+        error_messages.append("Network connection error.")
+
+    context = {
+        'title': 'Validate Event {0}'.format(event),
+        'event': event,
+        'page': page_url,
+        'error_messages': error_messages,
+    }
     return render(request, 'workshops/validate_event.html', context)
 
 
@@ -1017,14 +1046,48 @@ def event_delete(request, event_ident):
 def event_import(request):
     """Read tags from remote URL and return them as JSON.
 
-    This is used to read tags from workshop index page and then fill up fields
+    This is used to read tags from workshop website and then fill up fields
     on event_create form."""
+
+    url = request.POST['url'].strip()
     try:
-        url = request.POST['url']
-        translated_data = parse_tags_from_event_index(url)
-        return JsonResponse(translated_data)
-    except (KeyError, WrongEventURL):
-        raise SuspiciousOperation('Missing or wrong `url` POST parameter.')
+        # fetch page
+        response = requests.get(url)
+        response.raise_for_status()  # assert it's 200 OK
+        content = response.text
+
+        # find tags
+        tags = find_tags_on_event_website(content)
+
+        if 'slug' not in tags:
+            # there are no HTML tags, so let's try the old method
+            url = generate_url_to_event_index(url)
+
+            # fetch page
+            response = requests.get(url)
+
+            if response.status_code == 200:
+                # don't throw errors for pages we fall back to
+                content = response.text
+                tags = find_tags_on_event_index(content)
+
+        # normalize (parse) them
+        tags = parse_tags_from_event_website(tags)
+
+        return JsonResponse(tags)
+
+    except requests.exceptions.HTTPError as e:
+        raise SuspiciousOperation(
+            'Request for "{0}" returned status code {1}.'
+            .format(url, e.response.status_code)
+        )
+
+    except (requests.exceptions.ConnectionError,
+            requests.exceptions.Timeout):
+        raise SuspiciousOperation('Network connection error.')
+
+    except KeyError:
+        raise SuspiciousOperation('Missing or wrong "url" POST parameter.')
 
 
 @login_required


Test patch:
diff --git a/workshops/test/test_util.py b/workshops/test/test_util.py
--- a/workshops/test/test_util.py
+++ b/workshops/test/test_util.py
@@ -1,26 +1,26 @@
 # coding: utf-8
 import cgi
-from datetime import datetime, date
+import datetime
 from io import StringIO
-from importlib import import_module
-from unittest.mock import patch
 
-from django.conf import settings
 from django.contrib.sessions.serializers import JSONSerializer
 from django.test import TestCase
 from django.core.urlresolvers import reverse
-import requests
 
 from ..models import Host, Event, Role, Person, Task
 from ..util import (
     upload_person_task_csv,
     verify_upload_person_task,
-    normalize_event_index_url,
-    parse_tags_from_event_index,
+    generate_url_to_event_index,
+    find_tags_on_event_index,
+    find_tags_on_event_website,
+    parse_tags_from_event_website,
+    validate_tags_from_event_website,
 )
 
 from .base import TestBase
 
+
 class UploadPersonTaskCSVTestCase(TestCase):
 
     def compute_from_string(self, csv_str):
@@ -108,7 +108,7 @@ def setUp(self):
 
         Role.objects.create(name='Instructor')
         Role.objects.create(name='learner')
-        Event.objects.create(start=datetime.now(),
+        Event.objects.create(start=datetime.date.today(),
                              host=test_host,
                              slug='foobar',
                              admin_fee=100)
@@ -376,195 +376,330 @@ def test_attendance_increases(self):
         self.assertEqual(1, foobar.attendance)
 
 
-class TestEventURLNormalization(TestCase):
-    def setUp(self):
-        self.test_cases = [
-            'http://user-name.github.io/2015-07-13-City/',
-            'https://user-name.github.io/2015-07-13-City/',
-            'http://user-name.github.io/2015-07-13-City',
-            'https://user-name.github.io/2015-07-13-City',
-            'http://user-name.github.io/2015-07-13-City/index.html',
-            'https://github.com/user-name/2015-07-13-City/',
-            'https://github.com/user-name/2015-07-13-City',
-            ('https://github.com/user-name/2015-07-13-City/blob/'
-             'gh-pages/index.html'),
-        ]
-
-        self.output = ('https://raw.githubusercontent.com/user-name/'
-                       '2015-07-13-City/gh-pages/index.html')
-
-    def test_normalization(self):
-        for url in self.test_cases:
-            assert normalize_event_index_url(url)[0] == self.output
-
+class TestHandlingEventTags(TestCase):
+    maxDiff = None
 
-class TestParsingEventHeaders(TestCase):
-    maxDiff = None  # enable long diff in output
-
-    # Response.status_code apparently doesn't exist by default
-    @patch.object(requests.models.Response, 'status_code', 404, create=True)
-    def test_wrong_url(self):
-        with self.assertRaises(requests.exceptions.HTTPError):
-            url = 'http://test.github.io/2015-07-13-test/'
-            parse_tags_from_event_index(url)
-
-    @patch.object(requests, 'get')
-    def test_parsing_event_index(self, mock_get):
-        mock_get.return_value.status_code = 200
-        mock_get.return_value.text = """---
+    def test_generating_url_to_index(self):
+        tests = [
+            'http://swcarpentry.github.io/workshop-template',
+            'https://swcarpentry.github.com/workshop-template',
+            'http://swcarpentry.github.com/workshop-template/',
+        ]
+        expected = ('https://raw.githubusercontent.com/swcarpentry/'
+                    'workshop-template/gh-pages/index.html')
+        for url in tests:
+            with self.subTest(url=url):
+                self.assertEqual(expected, generate_url_to_event_index(url))
+
+    def test_finding_tags_on_index(self):
+        content = """---
 layout: workshop
 root: .
 venue: Euphoric State University
 address: Highway to Heaven 42, Academipolis
-country: USA
+country: us
 language: us
 latlng: 36.998977, -109.045173
 humandate: Jul 13-14, 2015
 humantime: 9:00 - 17:00
 startdate: 2015-07-13
-enddate: 2015-07-14
-instructor: ["Hermione Granger", "Harry Potter", "Ron Weasley",]
+enddate: "2015-07-14"
+instructor: ["Hermione Granger", "Ron Weasley",]
 helper: ["Peter Parker", "Tony Stark", "Natasha Romanova",]
-contact: hermione@granger.co.uk, rweasley@ministry.gov.uk
+contact: hermione@granger.co.uk, rweasley@ministry.gov
 etherpad:
 eventbrite: 10000000
----
+----
+Other content.
+"""
+        expected = {
+            'startdate': '2015-07-13',
+            'enddate': '2015-07-14',
+            'country': 'us',
+            'venue': 'Euphoric State University',
+            'address': 'Highway to Heaven 42, Academipolis',
+            'latlng': '36.998977, -109.045173',
+            'language': 'us',
+            'instructor': 'Hermione Granger, Ron Weasley',
+            'helper': 'Peter Parker, Tony Stark, Natasha Romanova',
+            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov',
+            'eventbrite': '10000000',
+        }
+        self.assertEqual(expected, find_tags_on_event_index(content))
+
+    def test_finding_tags_on_website(self):
+        content = """
+<html><head>
+<meta name="slug" content="2015-07-13-test" />
+<meta name="startdate" content="2015-07-13" />
+<meta name="enddate" content="2015-07-14" />
+<meta name="country" content="us" />
+<meta name="venue" content="Euphoric State University" />
+<meta name="address" content="Highway to Heaven 42, Academipolis" />
+<meta name="latlng" content="36.998977, -109.045173" />
+<meta name="language" content="us" />
+<meta name="invalid" content="invalid" />
+<meta name="instructor" content="Hermione Granger|Ron Weasley" />
+<meta name="helper" content="Peter Parker|Tony Stark|Natasha Romanova" />
+<meta name="contact" content="hermione@granger.co.uk, rweasley@ministry.gov" />
+<meta name="eventbrite" content="10000000" />
+<meta name="charset" content="utf-8" />
+</head>
+<body>
+<h1>test</h1>
+</body></html>
 """
-        url = 'http://test.github.io/2015-07-13-test/'
-        notes = """INSTRUCTORS: Hermione Granger, Harry Potter, Ron Weasley
-
-HELPERS: Peter Parker, Tony Stark, Natasha Romanova
-
-COUNTRY: USA"""
-
         expected = {
             'slug': '2015-07-13-test',
-            'start': date(2015, 7, 13),
-            'end': date(2015, 7, 14),
-            'url': 'https://test.github.io/2015-07-13-test/',
-            'reg_key': 1e7,
-            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov.uk',
-            'notes': notes,
+            'startdate': '2015-07-13',
+            'enddate': '2015-07-14',
+            'country': 'us',
             'venue': 'Euphoric State University',
             'address': 'Highway to Heaven 42, Academipolis',
-            'country': 'USA',
-            'latitude': '36.998977',
-            'longitude': '-109.045173',
+            'latlng': '36.998977, -109.045173',
+            'language': 'us',
+            'instructor': 'Hermione Granger|Ron Weasley',
+            'helper': 'Peter Parker|Tony Stark|Natasha Romanova',
+            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov',
+            'eventbrite': '10000000',
         }
 
-        self.assertEqual(parse_tags_from_event_index(url), expected)
-
-    @patch.object(requests, 'get')
-    def test_parsing_malformed_file(self, mock_get):
-        mock_get.return_value.status_code = 200
-        mock_get.return_value.text = """---
-venues: Euphoric State University
-addresses: Highway to Heaven 42, Academipolis
-countries: USA
-startdates: 2015-07-13
-enddates: 2015-07-14
-instructors: ["Hermione Granger", "Harry Potter", "Ron Weasley",]
-helpers: ["Peter Parker", "Tony Stark", "Natasha Romanova",]
-contacts: hermione@granger.co.uk, rweasley@ministry.gov.uk
-eventbrites: 10000000
----
-"""
-        url = 'http://test.github.io/2015-07-13-test/'
-        notes = "INSTRUCTORS: \n\nHELPERS: \n\nCOUNTRY: "
+        self.assertEqual(expected, find_tags_on_event_website(content))
 
+    def test_parsing_empty_tags(self):
+        empty_dict = {}
         expected = {
-            'slug': '2015-07-13-test',
-            'start': '',
-            'end': '',
-            'url': 'https://test.github.io/2015-07-13-test/',
-            'reg_key': '',
-            'contact': '',
-            'notes': notes,
+            'slug': '',
+            'language': '',
+            'start': None,
+            'end': None,
+            'country': '',
             'venue': '',
             'address': '',
-            'country': '',
-            'latitude': '',
-            'longitude': '',
+            'latitude': None,
+            'longitude': None,
+            'reg_key': None,
+            'instructors': [],
+            'helpers': [],
+            'contact': '',
         }
+        self.assertEqual(expected, parse_tags_from_event_website(empty_dict))
 
-        self.assertEqual(parse_tags_from_event_index(url), expected)
-
-    @patch.object(requests, 'get')
-    def test_parsing_empty_list_values(self, mock_get):
-        mock_get.return_value.status_code = 200
-        mock_get.return_value.text = """---
-venue: Euphoric State University
-address: Highway to Heaven 42, Academipolis
-country: USA
-language: us
-latlng: 36.998977, -109.045173
-humandate: Jul 13-14, 2015
-humantime: 9:00 - 17:00
-startdate: 2015-07-13
-enddate: 2015-07-14
-instructor:  # instructors
-helper:  # helpers
-contact: hermione@granger.co.uk, rweasley@ministry.gov.uk
-etherpad:
-eventbrite: 10000000
----
-"""
-        url = 'http://test.github.io/2015-07-13-test/'
-        notes = """INSTRUCTORS: \n\nHELPERS: \n\nCOUNTRY: USA"""
-
+    def test_parsing_correct_tags(self):
+        tags = {
+            'slug': '2015-07-13-test',
+            'startdate': '2015-07-13',
+            'enddate': '2015-07-14',
+            'country': 'us',
+            'venue': 'Euphoric State University',
+            'address': 'Highway to Heaven 42, Academipolis',
+            'latlng': '36.998977, -109.045173',
+            'language': 'us',
+            'instructor': 'Hermione Granger|Ron Weasley',
+            'helper': 'Peter Parker|Tony Stark|Natasha Romanova',
+            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov',
+            'eventbrite': '10000000',
+        }
         expected = {
             'slug': '2015-07-13-test',
-            'start': date(2015, 7, 13),
-            'end': date(2015, 7, 14),
-            'url': 'https://test.github.io/2015-07-13-test/',
-            'reg_key': 1e7,
-            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov.uk',
-            'notes': notes,
+            'language': 'US',
+            'start': datetime.date(2015, 7, 13),
+            'end': datetime.date(2015, 7, 14),
+            'country': 'US',
             'venue': 'Euphoric State University',
             'address': 'Highway to Heaven 42, Academipolis',
-            'country': 'USA',
-            'latitude': '36.998977',
-            'longitude': '-109.045173',
+            'latitude': 36.998977,
+            'longitude': -109.045173,
+            'reg_key': 10000000,
+            'instructors': ['Hermione Granger', 'Ron Weasley'],
+            'helpers': ['Peter Parker', 'Tony Stark', 'Natasha Romanova'],
+            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov',
+        }
+        self.assertEqual(expected, parse_tags_from_event_website(tags))
+
+    def test_parsing_tricky_country_language(self):
+        """Ensure we always get a 2-char string or nothing."""
+        tests = [
+            (('Usa', 'English'), ('US', 'EN')),
+            (('U', 'E'), ('', '')),
+            (('', ''), ('', '')),
+        ]
+        expected = {
+            'slug': '',
+            'language': '',
+            'start': None,
+            'end': None,
+            'country': '',
+            'venue': '',
+            'address': '',
+            'latitude': None,
+            'longitude': None,
+            'reg_key': None,
+            'instructors': [],
+            'helpers': [],
+            'contact': '',
         }
 
-        self.assertEqual(parse_tags_from_event_index(url), expected)
-
-    @patch.object(requests, 'get')
-    def test_parsing_2letter_country(self, mock_get):
-        mock_get.return_value.status_code = 200
-        mock_get.return_value.text = """---
-venue: Euphoric State University
-address: Highway to Heaven 42, Academipolis
-country: us
-startdate: 2015-07-13
-enddate: 2015-07-14
-instructor: ["Hermione Granger", "Harry Potter", "Ron Weasley",]
-helper: ["Peter Parker", "Tony Stark", "Natasha Romanova",]
-contact: hermione@granger.co.uk, rweasley@ministry.gov.uk
-eventbrite: 10000000
----
-"""
-        url = 'http://test.github.io/2015-07-13-test/'
-        rv = parse_tags_from_event_index(url)
-
-        self.assertEqual(rv['country'], 'US')
+        for (country, language), (country_exp, language_exp) in tests:
+            with self.subTest(iso_31661=(country, language)):
+                tags = dict(country=country, language=language)
+                expected['country'] = country_exp
+                expected['language'] = language_exp
+                self.assertEqual(expected, parse_tags_from_event_website(tags))
+
+    def test_parsing_tricky_dates(self):
+        """Test if non-dates don't get parsed."""
+        tests = [
+            (('wrong start date', 'wrong end date'), (None, None)),
+            (('11/19/2015', '11/19/2015'), (None, None)),
+        ]
+        expected = {
+            'slug': '',
+            'language': '',
+            'start': None,
+            'end': None,
+            'country': '',
+            'venue': '',
+            'address': '',
+            'latitude': None,
+            'longitude': None,
+            'reg_key': None,
+            'instructors': [],
+            'helpers': [],
+            'contact': '',
+        }
 
-    @patch.object(requests, 'get')
-    def test_parsing_old_format_country(self, mock_get):
-        mock_get.return_value.status_code = 200
-        mock_get.return_value.text = """---
-venue: Euphoric State University
-address: Highway to Heaven 42, Academipolis
-country: United-States
-startdate: 2015-07-13
-enddate: 2015-07-14
-instructor: ["Hermione Granger", "Harry Potter", "Ron Weasley",]
-helper: ["Peter Parker", "Tony Stark", "Natasha Romanova",]
-contact: hermione@granger.co.uk, rweasley@ministry.gov.uk
-eventbrite: 10000000
----
-"""
-        url = 'http://test.github.io/2015-07-13-test/'
-        rv = parse_tags_from_event_index(url)
+        for (startdate, enddate), (start, end) in tests:
+            with self.subTest(dates=(startdate, enddate)):
+                tags = dict(startdate=startdate, enddate=enddate)
+                expected['start'] = start
+                expected['end'] = end
+                self.assertEqual(expected, parse_tags_from_event_website(tags))
+
+    def test_parsing_tricky_list_of_names(self):
+        """Ensure we always get a list."""
+        tests = [
+            (('', ''), ([], [])),
+            (('Hermione Granger', 'Peter Parker'),
+             (['Hermione Granger'], ['Peter Parker'])),
+            (('Harry,Ron', 'Hermione,Ginny'),
+             (['Harry,Ron'], ['Hermione,Ginny'])),
+            (('Harry| Ron', 'Hermione |Ginny'),
+             (['Harry', 'Ron'], ['Hermione', 'Ginny'])),
+        ]
+        expected = {
+            'slug': '',
+            'language': '',
+            'start': None,
+            'end': None,
+            'country': '',
+            'venue': '',
+            'address': '',
+            'latitude': None,
+            'longitude': None,
+            'reg_key': None,
+            'instructors': [],
+            'helpers': [],
+            'contact': '',
+        }
 
-        self.assertEqual(rv['country'], 'US')
+        for (instructor, helper), (instructors, helpers) in tests:
+            with self.subTest(people=(instructor, helper)):
+                tags = dict(instructor=instructor, helper=helper)
+                expected['instructors'] = instructors
+                expected['helpers'] = helpers
+                self.assertEqual(expected, parse_tags_from_event_website(tags))
+
+    def test_parsing_tricky_latitude_longitude(self):
+        tests = [
+            ('XYZ', (None, None)),
+            ('XYZ, ', (None, None)),
+            (',-123', (None, -123.0)),
+            (',', (None, None)),
+        ]
+        expected = {
+            'slug': '',
+            'language': '',
+            'start': None,
+            'end': None,
+            'country': '',
+            'venue': '',
+            'address': '',
+            'latitude': None,
+            'longitude': None,
+            'reg_key': None,
+            'instructors': [],
+            'helpers': [],
+            'contact': '',
+        }
+        for latlng, (latitude, longitude) in tests:
+            with self.subTest(latlng=latlng):
+                tags = dict(latlng=latlng)
+                expected['latitude'] = latitude
+                expected['longitude'] = longitude
+                self.assertEqual(expected, parse_tags_from_event_website(tags))
+
+    def test_validating_invalid_tags(self):
+        tags = {
+            'slug': 'WRONG FORMAT',
+            'language': 'ENGLISH',
+            'startdate': '07/13/2015',
+            'enddate': '07/14/2015',
+            'country': 'USA',
+            'venue': 'Euphoric State University',
+            'address': 'Highway to Heaven 42, Academipolis',
+            'latlng': '3699e-4, -1.09e2',
+            'instructor': 'Hermione Granger, Ron Weasley',
+            'helper': 'Peter Parker, Tony Stark, Natasha Romanova',
+            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov',
+            'eventbrite': 'bigmoney',
+        }
+        errors = validate_tags_from_event_website(tags)
+        assert len(errors) == 7
+        assert all([error.startswith('Invalid value') for error in errors])
+
+    def test_validating_missing_tags(self):
+        tags = {}
+        errors = validate_tags_from_event_website(tags)
+        assert len(errors) == 12
+        assert all([error.startswith('Missing') for error in errors])
+
+    def test_validating_default_tags(self):
+        tags = {
+            'slug': 'FIXME',
+            'language': 'FIXME',
+            'startdate': 'FIXME',
+            'enddate': 'FIXME',
+            'country': 'FIXME',
+            'venue': 'FIXME',
+            'address': 'FIXME',
+            'latlng': 'FIXME',
+            'eventbrite': 'FIXME',
+            'instructor': 'FIXME',
+            'helper': 'FIXME',
+            'contact': 'FIXME',
+        }
+        errors = validate_tags_from_event_website(tags)
+        assert len(errors) == 12
+        assert all([
+            error.startswith('Placeholder value "FIXME"')
+            for error in errors
+        ])
+
+    def test_validating_correct_tags(self):
+        tags = {
+            'slug': '2015-07-13-test',
+            'language': 'us',
+            'startdate': '2015-07-13',
+            'enddate': '2015-07-14',
+            'country': 'us',
+            'venue': 'Euphoric State University',
+            'address': 'Highway to Heaven 42, Academipolis',
+            'latlng': '36.998977, -109.045173',
+            'eventbrite': '10000000',
+            'instructor': 'Hermione Granger, Ron Weasley',
+            'helper': 'Peter Parker, Tony Stark, Natasha Romanova',
+            'contact': 'hermione@granger.co.uk, rweasley@ministry.gov',
+        }
+        errors = validate_tags_from_event_website(tags)
+        assert not errors