From 173f7c37a4597a223e22ef62ff0a1b34e32ac49f Mon Sep 17 00:00:00 2001 From: Jan-Pascal van Best Date: Wed, 10 Feb 2016 22:38:51 +0100 Subject: [PATCH] Mostly works --- .gitignore | 1 + TODO | 3 + dependencies | 7 + tweet/__init__.py | 29 + tweet/config.py | 16 +- tweet/jobrunner.py | 108 +- tweet/migrations/0005_auto_20160205_0946.py | 43 + tweet/migrations/0006_auto_20160205_1024.py | 25 + tweet/migrations/0007_auto_20160205_1132.py | 26 + tweet/models.py | 202 +- tweet/static/tweet/favicon.ico | Bin 0 -> 1150 bytes .../static/tweet/javascript/bootstrap.min.js | 7 +- .../tweet/javascript/jquery-1.9.0.min.js | 4 - .../javascript/jquery-ui-1.10.3.custom.min.js | 7 - .../static/tweet/javascript/jquery-ui.min.js | 1 + tweet/static/tweet/javascript/jquery.min.js | 1 + tweet/static/tweet/stylesheets/bootstrap.css | 2471 +---------------- .../tweet/stylesheets/bootstrap.min.css | 10 +- tweet/static/tweet/stylesheets/ui-lightness | 1 + .../ui-lightness/images/animated-overlay.gif | Bin 1738 -> 0 bytes .../ui-bg_diagonals-thick_18_b81900_40x40.png | Bin 418 -> 0 bytes .../ui-bg_diagonals-thick_20_666666_40x40.png | Bin 312 -> 0 bytes .../images/ui-bg_flat_10_000000_40x100.png | Bin 205 -> 0 bytes .../images/ui-bg_glass_100_f6f6f6_1x400.png | Bin 262 -> 0 bytes .../images/ui-bg_glass_100_fdf5ce_1x400.png | Bin 348 -> 0 bytes .../images/ui-bg_glass_65_ffffff_1x400.png | Bin 207 -> 0 bytes .../ui-bg_gloss-wave_35_f6a828_500x100.png | Bin 5815 -> 0 bytes .../ui-bg_highlight-soft_100_eeeeee_1x100.png | Bin 278 -> 0 bytes .../ui-bg_highlight-soft_75_ffe45c_1x100.png | Bin 328 -> 0 bytes .../images/ui-icons_222222_256x240.png | Bin 6922 -> 0 bytes .../images/ui-icons_228ef1_256x240.png | Bin 4549 -> 0 bytes .../images/ui-icons_ef8c08_256x240.png | Bin 4549 -> 0 bytes .../images/ui-icons_ffd27a_256x240.png | Bin 4549 -> 0 bytes .../images/ui-icons_ffffff_256x240.png | Bin 6299 -> 0 bytes .../ui-lightness/jquery-ui-1.10.3.custom.css | 1177 -------- .../jquery-ui-1.10.3.custom.min.css | 5 - tweet/streamrunner.py | 69 + tweet/templates/tweet/base.html | 29 +- tweet/templates/tweet/edit_stream_terms.html | 66 + tweet/templates/tweet/index.html | 2 +- tweet/templates/tweet/list_jobs.html | 66 +- tweet/templates/tweet/list_stream.html | 53 + tweet/templates/tweet/pagination_snippet.html | 20 + tweet/templates/tweet/selectconfig.html | 2 + tweet/urls.py | 10 + tweet/utils.py | 68 + tweet/views.py | 149 +- tweet_django/settings.py | 31 + 48 files changed, 924 insertions(+), 3785 deletions(-) create mode 100644 TODO create mode 100644 tweet/migrations/0005_auto_20160205_0946.py create mode 100644 tweet/migrations/0006_auto_20160205_1024.py create mode 100644 tweet/migrations/0007_auto_20160205_1132.py create mode 100644 tweet/static/tweet/favicon.ico mode change 100644 => 120000 tweet/static/tweet/javascript/bootstrap.min.js delete mode 100644 tweet/static/tweet/javascript/jquery-1.9.0.min.js delete mode 100644 tweet/static/tweet/javascript/jquery-ui-1.10.3.custom.min.js create mode 120000 tweet/static/tweet/javascript/jquery-ui.min.js create mode 120000 tweet/static/tweet/javascript/jquery.min.js mode change 100644 => 120000 tweet/static/tweet/stylesheets/bootstrap.css mode change 100644 => 120000 tweet/static/tweet/stylesheets/bootstrap.min.css create mode 120000 tweet/static/tweet/stylesheets/ui-lightness delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/animated-overlay.gif delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_diagonals-thick_18_b81900_40x40.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_diagonals-thick_20_666666_40x40.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_flat_10_000000_40x100.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_glass_100_f6f6f6_1x400.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_glass_100_fdf5ce_1x400.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_glass_65_ffffff_1x400.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_gloss-wave_35_f6a828_500x100.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_highlight-soft_100_eeeeee_1x100.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-bg_highlight-soft_75_ffe45c_1x100.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-icons_222222_256x240.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-icons_228ef1_256x240.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-icons_ef8c08_256x240.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-icons_ffd27a_256x240.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/images/ui-icons_ffffff_256x240.png delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/jquery-ui-1.10.3.custom.css delete mode 100644 tweet/static/tweet/stylesheets/ui-lightness/jquery-ui-1.10.3.custom.min.css create mode 100644 tweet/streamrunner.py create mode 100644 tweet/templates/tweet/edit_stream_terms.html create mode 100644 tweet/templates/tweet/list_stream.html create mode 100644 tweet/templates/tweet/pagination_snippet.html create mode 100644 tweet/utils.py diff --git a/.gitignore b/.gitignore index 0b6bd05..8cf8bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /results __pycache__ *.pyc +*.log diff --git a/TODO b/TODO new file mode 100644 index 0000000..026c16e --- /dev/null +++ b/TODO @@ -0,0 +1,3 @@ +- Reverse geocoding: the Nominatim service used currently will give an error + when it is used too heavily +- Time zones: Check time zone handling in all use cases diff --git a/dependencies b/dependencies index 806a860..d49ef03 100644 --- a/dependencies +++ b/dependencies @@ -2,3 +2,10 @@ python3-django python3-mysqldb python3-requests-oauthlib python3-twython +python3-xlsxwriter +python3-geopy +python3-django-jsonfield (>= 0.9.15-2) +libjs-jquery +libjs-jquery-ui +libjs-jquery-ui-theme-ui-lightness +libjs-bootstrap diff --git a/tweet/__init__.py b/tweet/__init__.py index 5785fa0..012103f 100644 --- a/tweet/__init__.py +++ b/tweet/__init__.py @@ -1 +1,30 @@ # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 + +import threading +import time + +import django.apps + +_started = False + +def startup_thread(): + global _started + + if _started: + return + _started = True + print("Waiting until app starts..") + # Wait until the app started before accessing the database + while not django.apps.apps.ready: + time.sleep(1) + + # Only import streamrunner here, because it imports models which won't + # be available earlier + from . import streamrunner + from .models import Settings + settings = Settings.get() + streamrunner.start_stream(settings.stream_terms) + +print("Starting startup thread...") +thread = threading.Thread(target=startup_thread) +thread.start() diff --git a/tweet/config.py b/tweet/config.py index 4587d99..7cd57b3 100644 --- a/tweet/config.py +++ b/tweet/config.py @@ -12,9 +12,9 @@ class Config: def __init__(self, f): self.load(f) - def load(self, f): + def load(self, filename): config = configparser.ConfigParser() - config.read_file(f) + config.read(filename) self.page_size = config.getint('general', 'pagesize', fallback=100) self.max_pages = config.getint('general', 'maxpages', fallback=500) @@ -25,12 +25,18 @@ class Config: terms = config['excel'][key].split(",") self.excels[key] = terms - print("terms: " + str(self.terms)) - print("excels: " + str(self.excels)) - def query_names(self): return self.terms.keys() + def query(self, name): + return self.terms[name] + def all_search_terms(self): return self.terms.values() + + def excel_names(self): + return self.excels.keys() + + def excel_query(self, excel_name): + return self.excels[excel_name] diff --git a/tweet/jobrunner.py b/tweet/jobrunner.py index 67f581b..5a52fc4 100644 --- a/tweet/jobrunner.py +++ b/tweet/jobrunner.py @@ -1,18 +1,59 @@ # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 -import time from concurrent.futures import ThreadPoolExecutor +import logging +import os.path +import time from django.conf import settings from twython import Twython +from .models import Job, Tweet +from .utils import ExcelExporter + +# Get an instance of a logger +logger = logging.getLogger(__name__) + _running = [] _pool = ThreadPoolExecutor(max_workers=15) -def _handle_tweet(query_name, status): - print("==========") - print(status) +class JobHandler: + def __init__(self, job): + self.job = job + self.config = job.get_config() + self.exporters = [ + ( self.config.excel_query(filename), + ExcelExporter(os.path.join(self.job.job_path(), filename)) + ) + for filename in self.config.excel_names() ] + + def log(self, s): + logger.info(s) + self.job.add_log(s) + + def tweet(self, query_name, status): + self.job.num_tweets += 1 + self.job.save() + #self.log(str(tweet)) + for (terms, exporter) in self.exporters: + if query_name in terms: + tweet = Tweet.from_status(status) + exporter.add_tweet(tweet) + tweet.delete() + + #self.log(" [{}] {}".format(self.config.query(query_name), tweet["text"])) + + def status(self, status, seconds=None): + self.job.status = status + if seconds is not None: + self.job.seconds_to_wait = seconds + self.job.save() + + def close(self): + self.job.close_log() + for (terms, exporter) in self.exporters: + exporter.close() def _get_rate_status(twitter): rate_status = None @@ -29,12 +70,11 @@ def _get_rate_status(twitter): time.sleep(1) print("Sleep time remaining: {} seconds".format(i)) - print("A") return rate_status -def _fetch(query_name, terms, max_pages, page_size): - print("_fetch...") +def _fetch(query_name, terms, max_pages, page_size, handler): + handler.log("_fetch()") # TODO use global twitter object? twitter = Twython(settings.TWEET_OAUTH_CONSUMER_KEY, settings.TWEET_OAUTH_CONSUMER_SECRET, @@ -42,25 +82,25 @@ def _fetch(query_name, terms, max_pages, page_size): settings.TWEET_OAUTH_ACCESS_TOKEN_SECRET) rate_status = _get_rate_status(twitter) - print("B") remaining = rate_status["remaining"] - print("C") max_id = -1 previous_last_id = float('inf') - print("max_pages: {}".format(max_pages)) + handler.log("max_pages: {}".format(max_pages)) for k in range(max_pages): - print("k: {}".format(k)) while remaining<10: - seconds = rate_status["reset"] - time.time() - print("Running into Twitter rate limiting, need to wait {} seconds...".format(seconds)) - for i in range(max(60, seconds), 0, -1): - print("still waiting: {}".format(i)) + seconds = int(rate_status["reset"] - time.time()) + handler.log("Twitter rate limiting, need to wait {} seconds...".format(seconds)) + handler.status(Job.STATUS_WAITING, seconds) + for i in range(min(60, seconds)): + handler.status(Job.STATUS_WAITING, seconds-i) time.sleep(1) rate_status = _get_rate_status(twitter) - remaining = rate_status.remaining + remaining = rate_status["remaining"] remaining -= 1 + handler.status(Job.STATUS_RUNNING) + query = {'q':terms, 'result_type':'recent', 'count': page_size} if max_id>0: query["max_id"]= max_id @@ -68,45 +108,55 @@ def _fetch(query_name, terms, max_pages, page_size): try: results = twitter.search(**query) except TwythonError as e: - print("search exception, error code = {}: {}".format(e.error_code, e)) + handler.log("search exception, error code = {}: {}".format(e.error_code, e)) + handler.status(Job.STATUS_FAILED) # FIXME break remaining = int(twitter.get_lastfunction_header('x-rate-limit-remaining')) - print("remaining: {}".format(remaining)) + handler.log("remaining: {}".format(remaining)) max_id = results["search_metadata"]["max_id"] - print("Number of results: {}".format(results["search_metadata"]["count"])) + handler.log("Number of results: {}".format(results["search_metadata"]["count"])) last_id = float('inf') for status in results["statuses"]: - _handle_tweet(query_name, status) + handler.tweet(query_name, status) if status["id"]=previous_last_id: - print("max_id not descending, quitting") + handler.log("max_id not descending, quitting") break previous_last_id = max_id if k >= max_pages-1: - print("Warning, more than {} pages of results, ignoring the rest!!!".format(max_pages)); + handler.log("Warning, more than {} pages of results, ignoring the rest!!!".format(max_pages)); #results = twitter.search(q='nlalert', result_type='recent') # since_id=, max_id=,count=, #for result in results: # print(results[result]) # Runs in a thread pool thread -def _execute_job(job): +def _execute_job(job, handler): + logger.debug("_execute_job") try: - _fetch("testing", "amsterdam", 4, 10) + config = job.get_config() + handler.status(Job.STATUS_RUNNING) + print(str(config.query_names())) + for query_name in config.query_names(): + handler.log("Running query {}".format(query_name)); + _fetch(query_name, config.query(query_name), config.max_pages, config.page_size, handler) + handler.status(Job.STATUS_DONE) except Exception as e: - print("Exception in _execute_job:") - print(str(e)) + handler.status(Job.STATUS_FAILED) + logger.exception("Exception in _execute_job") + handler.close() + logger.debug("_execute_job done") -def run_job(job): +def run_job(job, handler): _running.append(job.id) - _pool.submit(_execute_job, job) + _pool.submit(_execute_job, job, handler) diff --git a/tweet/migrations/0005_auto_20160205_0946.py b/tweet/migrations/0005_auto_20160205_0946.py new file mode 100644 index 0000000..73f6883 --- /dev/null +++ b/tweet/migrations/0005_auto_20160205_0946.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-05 08:46 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tweet', '0004_auto_20160201_0937'), + ] + + operations = [ + migrations.CreateModel( + name='Settings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stream_terms_json', models.TextField(verbose_name='Stream search terms')), + ], + ), + migrations.RemoveField( + model_name='joboutput', + name='job_description', + ), + migrations.RemoveField( + model_name='joboutput', + name='queries', + ), + migrations.RemoveField( + model_name='jobquery', + name='job_description', + ), + migrations.DeleteModel( + name='JobDescription', + ), + migrations.DeleteModel( + name='JobOutput', + ), + migrations.DeleteModel( + name='JobQuery', + ), + ] diff --git a/tweet/migrations/0006_auto_20160205_1024.py b/tweet/migrations/0006_auto_20160205_1024.py new file mode 100644 index 0000000..ae62199 --- /dev/null +++ b/tweet/migrations/0006_auto_20160205_1024.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-05 09:24 +from __future__ import unicode_literals + +from django.db import migrations +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tweet', '0005_auto_20160205_0946'), + ] + + operations = [ + migrations.RemoveField( + model_name='settings', + name='stream_terms_json', + ), + migrations.AddField( + model_name='settings', + name='stream_terms', + field=jsonfield.fields.JSONField(default=['nlalert', 'amsterdam'], verbose_name='Stream search terms'), + ), + ] diff --git a/tweet/migrations/0007_auto_20160205_1132.py b/tweet/migrations/0007_auto_20160205_1132.py new file mode 100644 index 0000000..b8be8a2 --- /dev/null +++ b/tweet/migrations/0007_auto_20160205_1132.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-05 10:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tweet', '0006_auto_20160205_1024'), + ] + + operations = [ + migrations.AlterField( + model_name='settings', + name='stream_terms', + field=jsonfield.fields.JSONField(default=['nlalert', 'rotterdam'], verbose_name='Stream search terms'), + ), + migrations.AlterField( + model_name='tweet', + name='id', + field=models.BigIntegerField(primary_key=True, serialize=False, verbose_name='id'), + ), + ] diff --git a/tweet/models.py b/tweet/models.py index db8bc80..9240bc7 100644 --- a/tweet/models.py +++ b/tweet/models.py @@ -1,14 +1,51 @@ # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 +import datetime +from enum import Enum +import json +import logging +import os.path +import shutil +import zipfile + from django.conf import settings from django.db import models from django.utils import timezone -import os.path -from enum import Enum -from . import config +import jsonfield +from geopy.geocoders import OpenMapQuest +from geopy.exc import GeocoderServiceError + +from .config import Config + +logger = logging.getLogger(__name__) + +class Settings(models.Model): + stream_terms = jsonfield.JSONField('Stream search terms', default=["nlalert", "rotterdam"]) + + def __str__(self): + return str(self.stream_terms) + +# stream_terms is now a JSONField instead of TextField, keeping this code in +# case JSONField breaks, it's giving Deprecation warnings already... +# @property +# def stream_terms(self): +# return json.loads(self.stream_terms_json) +# +# @stream_terms.setter +# def stream_terms(self, terms): +# self.stream_terms = json.dumps(stream_terms) + + @staticmethod + def get(): + settings = Settings.objects.first() + if settings is None: + settings = Settings() + settings.save() + return settings class Tweet(models.Model): + id = models.BigIntegerField('id', primary_key=True) created_at = models.DateTimeField('date and time', default=timezone.now) from_user = models.CharField('sender screen name', max_length=20) from_user_id = models.BigIntegerField('sender user id') @@ -24,28 +61,37 @@ class Tweet(models.Model): class Meta: db_table = "tweet" -class JobDescription(models.Model): - name = models.CharField("job name", max_length=80) - - def __str__(self): - return self.name - - -class JobQuery(models.Model): - job_description = models.ForeignKey(JobDescription, on_delete=models.CASCADE) - name = models.CharField("name", max_length=80) - terms = models.TextField("search terms") - - def __str__(self): - return self.name - -class JobOutput(models.Model): - job_description = models.ForeignKey(JobDescription, on_delete=models.CASCADE) - filename = models.CharField("file name", max_length=1024) - queries = models.ManyToManyField(JobQuery) - - def __str__(self): - return self.filename + @staticmethod + def from_status(status): + in_reply_to = None + if "in_reply_to_screen_name" in status: + in_reply_to = status["in_reply_to_screen_name"] + if in_reply_to is None: + in_reply_to = "" + tweet = Tweet( + id = status["id"], + created_at = datetime.datetime.strptime(status["created_at"], "%a %b %d %H:%M:%S %z %Y"), + from_user = status["user"]["screen_name"], + from_user_id = str(status["user"]["id"]), + in_reply_to = in_reply_to, + text = status["text"], + conforms_to_terms = True) + if status["geo"] is not None: + (tweet.latitude,tweet.longitude) = status['geo']['coordinates'] + return tweet + + def location(self): + if self.latitude is not None and self.longitude is not None: + try: + geolocator = OpenMapQuest(api_key = settings.TWEET_MAPQUEST_API_KEY, timeout=10) + #print("lat,long:[{},{}]".format(self.latitude,self.longitude)) + location = geolocator.reverse((self.latitude, self.longitude), exactly_one=True) + return location.address + except GeocoderServiceError as e: + logger.exception("Error during reverse geocoding") + return None + else: + return None class Job(models.Model): STATUS_INIT = 0 @@ -54,6 +100,14 @@ class Job(models.Model): STATUS_FAILED = 3 STATUS_DONE = 4 + STATUS_STRINGS = { + STATUS_INIT: "Starting", + STATUS_RUNNING: "Running", + STATUS_WAITING: "Waiting", + STATUS_FAILED: "Failed", + STATUS_DONE: "Done" + } + name = models.CharField("name", max_length=80) start_time = models.DateTimeField("start time", default=timezone.now) num_tweets = models.BigIntegerField("number of tweets found", default=0) @@ -66,17 +120,32 @@ class Job(models.Model): self.xls = None self.zipfile = None self.logfile = None - self.logwriter = None + self.loghandle = None self.loaded = False self.config = None def __str__(self): return self.name + def remove(self): + shutil.rmtree(self.job_path()) + self.delete() + + def status_string(self): + if self.status == Job.STATUS_WAITING: + return "Waiting ({}s)".format(self.seconds_to_wait) + elif self.status in Job.STATUS_STRINGS: + return Job.STATUS_STRINGS[self.status] + else: + return "Unknown" + def job_path(self): base_path = settings.TWEET_BASEPATH - path = os.path.join(settings.TWEET_BASEPATH, str(self.id)) - os.makedirs(path) + path = os.path.join(base_path, str(self.id)) + try: + os.makedirs(path) + except FileExistsError: + pass return path def add_config(self, f): @@ -84,23 +153,84 @@ class Job(models.Model): with open(self.inifile, 'wb+') as destination: for chunk in f.chunks(): destination.write(chunk) - with open(self.inifile, 'r') as ini: - self.config = config.Config(ini) + + def get_inifile(self): + if self.inifile is None: + self.inifile = os.path.join(self.job_path(), "config.ini") + return self.inifile def get_config(self): - if self.config is not None: - return self.config - inifile = os.path.join(self.job_path(), "config.ini") - self.config = Config(inifile) + if self.config is None: + self.config = Config(self.get_inifile()) return self.config + def get_logfile(self): + if self.logfile is None: + self.logfile = os.path.join(self.job_path(), "log.txt") + return self.logfile + + def get_zipfile(self): + if self.zipfile is None: + self.zipfile = os.path.join(self.job_path(), "results.zip") + with zipfile.ZipFile(self.zipfile, "w", zipfile.ZIP_DEFLATED, True) as zip: + for filename in self.get_results(): + zip.write(os.path.join(self.job_path(), filename), filename) + zip.write(self.get_inifile(), "config.ini") + zip.write(self.get_logfile(), "log.txt") + + return self.zipfile + + def get_loghandle(self): + if self.loghandle is None: + self.loghandle = open(self.get_logfile(), "w") + return self.loghandle + + def add_log(self, s): + f = self.get_loghandle() + f.write(s) + f.write("\n") + + def close_log(self): + f = self.get_loghandle() + f.close() + self.loghandle = None + def get_queries(self): result = set() try: config = self.get_config() result = config.all_search_terms() except Exception as e: - print("Exception getting config:") - print(e) + logging.exception("Exception getting config") return result + def get_results(self): + if self.xls is None: + job_dir = self.job_path() + self.xls = [fn for fn in os.listdir(job_dir) + if any(fn.endswith(ext) for ext in + [".xls",".xlsx"])] + return self.xls + +##class JobDescription(models.Model): +## name = models.CharField("job name", max_length=80) +## +## def __str__(self): +## return self.name +## +## +##class JobQuery(models.Model): +## job_description = models.ForeignKey(JobDescription, on_delete=models.CASCADE) +## name = models.CharField("name", max_length=80) +## terms = models.TextField("search terms") +## +## def __str__(self): +## return self.name +## +##class JobOutput(models.Model): +## job_description = models.ForeignKey(JobDescription, on_delete=models.CASCADE) +## filename = models.CharField("file name", max_length=1024) +## queries = models.ManyToManyField(JobQuery) +## +## def __str__(self): +## return self.filename diff --git a/tweet/static/tweet/favicon.ico b/tweet/static/tweet/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8a66efe9314195b51575f93cfd0631105a9dbbff GIT binary patch literal 1150 zcmc&!T_{6g7=A^$k?jJv$AycPV&zu2l1mrvxpZglwYXUnBNsyQ6E%x)C1IOLR;z90 zr-`;{w5FUh4rZdy`(&p+Y+O30=e%F<`#jJ0zUMm~q9VS{X5wcxb(auT5s`(#Db9T6 zIS_H}`z3@RMlqv;p)hJ0mfRXS)W9hOrYvfSNdFAzq z`0N9lgJ#t;L?S#vBIzQbu1eT2>F|2bYR&AE*8{z!lR<0Z$UbuEQf|KJb6)qm){9>} z;M?#Ln;k;$`Si%aV@?+@W`a2b>Ltxd;_74uDK^Is-Sk`w>t~z^`LE;ITrQG*Opi!;T>1v_y(Xe5cNBI zQIkDzMws{7U9Mkjb-AE9n63}MW>k}*Ga_=Rfgf8Sf!RF6ru6Za&AB&e4W-W4JHXAS zl|8^eoNw{Tv!QOxX>k815?u-YD>M_u6^4muuk;iB#Y6@iMMQH<^KlfT4AuZ2GGg6l literal 0 HcmV?d00001 diff --git a/tweet/static/tweet/javascript/bootstrap.min.js b/tweet/static/tweet/javascript/bootstrap.min.js deleted file mode 100644 index f9cbdae..0000000 --- a/tweet/static/tweet/javascript/bootstrap.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/*! -* Bootstrap.js by @fat & @mdo -* Copyright 2012 Twitter, Inc. -* http://www.apache.org/licenses/LICENSE-2.0.txt -*/ -!function(e){"use strict";e(function(){e.support.transition=function(){var e=function(){var e=document.createElement("bootstrap"),t={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"},n;for(n in t)if(e.style[n]!==undefined)return t[n]}();return e&&{end:e}}()})}(window.jQuery),!function(e){"use strict";var t='[data-dismiss="alert"]',n=function(n){e(n).on("click",t,this.close)};n.prototype.close=function(t){function s(){i.trigger("closed").remove()}var n=e(this),r=n.attr("data-target"),i;r||(r=n.attr("href"),r=r&&r.replace(/.*(?=#[^\s]*$)/,"")),i=e(r),t&&t.preventDefault(),i.length||(i=n.hasClass("alert")?n:n.parent()),i.trigger(t=e.Event("close"));if(t.isDefaultPrevented())return;i.removeClass("in"),e.support.transition&&i.hasClass("fade")?i.on(e.support.transition.end,s):s()};var r=e.fn.alert;e.fn.alert=function(t){return this.each(function(){var r=e(this),i=r.data("alert");i||r.data("alert",i=new n(this)),typeof t=="string"&&i[t].call(r)})},e.fn.alert.Constructor=n,e.fn.alert.noConflict=function(){return e.fn.alert=r,this},e(document).on("click.alert.data-api",t,n.prototype.close)}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.button.defaults,n)};t.prototype.setState=function(e){var t="disabled",n=this.$element,r=n.data(),i=n.is("input")?"val":"html";e+="Text",r.resetText||n.data("resetText",n[i]()),n[i](r[e]||this.options[e]),setTimeout(function(){e=="loadingText"?n.addClass(t).attr(t,t):n.removeClass(t).removeAttr(t)},0)},t.prototype.toggle=function(){var e=this.$element.closest('[data-toggle="buttons-radio"]');e&&e.find(".active").removeClass("active"),this.$element.toggleClass("active")};var n=e.fn.button;e.fn.button=function(n){return this.each(function(){var r=e(this),i=r.data("button"),s=typeof n=="object"&&n;i||r.data("button",i=new t(this,s)),n=="toggle"?i.toggle():n&&i.setState(n)})},e.fn.button.defaults={loadingText:"loading..."},e.fn.button.Constructor=t,e.fn.button.noConflict=function(){return e.fn.button=n,this},e(document).on("click.button.data-api","[data-toggle^=button]",function(t){var n=e(t.target);n.hasClass("btn")||(n=n.closest(".btn")),n.button("toggle")})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.$indicators=this.$element.find(".carousel-indicators"),this.options=n,this.options.pause=="hover"&&this.$element.on("mouseenter",e.proxy(this.pause,this)).on("mouseleave",e.proxy(this.cycle,this))};t.prototype={cycle:function(t){return t||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(e.proxy(this.next,this),this.options.interval)),this},getActiveIndex:function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},to:function(t){var n=this.getActiveIndex(),r=this;if(t>this.$items.length-1||t<0)return;return this.sliding?this.$element.one("slid",function(){r.to(t)}):n==t?this.pause().cycle():this.slide(t>n?"next":"prev",e(this.$items[t]))},pause:function(t){return t||(this.paused=!0),this.$element.find(".next, .prev").length&&e.support.transition.end&&(this.$element.trigger(e.support.transition.end),this.cycle(!0)),clearInterval(this.interval),this.interval=null,this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(t,n){var r=this.$element.find(".item.active"),i=n||r[t](),s=this.interval,o=t=="next"?"left":"right",u=t=="next"?"first":"last",a=this,f;this.sliding=!0,s&&this.pause(),i=i.length?i:this.$element.find(".item")[u](),f=e.Event("slide",{relatedTarget:i[0],direction:o});if(i.hasClass("active"))return;this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid",function(){var t=e(a.$indicators.children()[a.getActiveIndex()]);t&&t.addClass("active")}));if(e.support.transition&&this.$element.hasClass("slide")){this.$element.trigger(f);if(f.isDefaultPrevented())return;i.addClass(t),i[0].offsetWidth,r.addClass(o),i.addClass(o),this.$element.one(e.support.transition.end,function(){i.removeClass([t,o].join(" ")).addClass("active"),r.removeClass(["active",o].join(" ")),a.sliding=!1,setTimeout(function(){a.$element.trigger("slid")},0)})}else{this.$element.trigger(f);if(f.isDefaultPrevented())return;r.removeClass("active"),i.addClass("active"),this.sliding=!1,this.$element.trigger("slid")}return s&&this.cycle(),this}};var n=e.fn.carousel;e.fn.carousel=function(n){return this.each(function(){var r=e(this),i=r.data("carousel"),s=e.extend({},e.fn.carousel.defaults,typeof n=="object"&&n),o=typeof n=="string"?n:s.slide;i||r.data("carousel",i=new t(this,s)),typeof n=="number"?i.to(n):o?i[o]():s.interval&&i.pause().cycle()})},e.fn.carousel.defaults={interval:5e3,pause:"hover"},e.fn.carousel.Constructor=t,e.fn.carousel.noConflict=function(){return e.fn.carousel=n,this},e(document).on("click.carousel.data-api","[data-slide], [data-slide-to]",function(t){var n=e(this),r,i=e(n.attr("data-target")||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,"")),s=e.extend({},i.data(),n.data()),o;i.carousel(s),(o=n.attr("data-slide-to"))&&i.data("carousel").pause().to(o).cycle(),t.preventDefault()})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.collapse.defaults,n),this.options.parent&&(this.$parent=e(this.options.parent)),this.options.toggle&&this.toggle()};t.prototype={constructor:t,dimension:function(){var e=this.$element.hasClass("width");return e?"width":"height"},show:function(){var t,n,r,i;if(this.transitioning||this.$element.hasClass("in"))return;t=this.dimension(),n=e.camelCase(["scroll",t].join("-")),r=this.$parent&&this.$parent.find("> .accordion-group > .in");if(r&&r.length){i=r.data("collapse");if(i&&i.transitioning)return;r.collapse("hide"),i||r.data("collapse",null)}this.$element[t](0),this.transition("addClass",e.Event("show"),"shown"),e.support.transition&&this.$element[t](this.$element[0][n])},hide:function(){var t;if(this.transitioning||!this.$element.hasClass("in"))return;t=this.dimension(),this.reset(this.$element[t]()),this.transition("removeClass",e.Event("hide"),"hidden"),this.$element[t](0)},reset:function(e){var t=this.dimension();return this.$element.removeClass("collapse")[t](e||"auto")[0].offsetWidth,this.$element[e!==null?"addClass":"removeClass"]("collapse"),this},transition:function(t,n,r){var i=this,s=function(){n.type=="show"&&i.reset(),i.transitioning=0,i.$element.trigger(r)};this.$element.trigger(n);if(n.isDefaultPrevented())return;this.transitioning=1,this.$element[t]("in"),e.support.transition&&this.$element.hasClass("collapse")?this.$element.one(e.support.transition.end,s):s()},toggle:function(){this[this.$element.hasClass("in")?"hide":"show"]()}};var n=e.fn.collapse;e.fn.collapse=function(n){return this.each(function(){var r=e(this),i=r.data("collapse"),s=e.extend({},e.fn.collapse.defaults,r.data(),typeof n=="object"&&n);i||r.data("collapse",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.collapse.defaults={toggle:!0},e.fn.collapse.Constructor=t,e.fn.collapse.noConflict=function(){return e.fn.collapse=n,this},e(document).on("click.collapse.data-api","[data-toggle=collapse]",function(t){var n=e(this),r,i=n.attr("data-target")||t.preventDefault()||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,""),s=e(i).data("collapse")?"toggle":n.data();n[e(i).hasClass("in")?"addClass":"removeClass"]("collapsed"),e(i).collapse(s)})}(window.jQuery),!function(e){"use strict";function r(){e(".dropdown-backdrop").remove(),e(t).each(function(){i(e(this)).removeClass("open")})}function i(t){var n=t.attr("data-target"),r;n||(n=t.attr("href"),n=n&&/#/.test(n)&&n.replace(/.*(?=#[^\s]*$)/,"")),r=n&&e(n);if(!r||!r.length)r=t.parent();return r}var t="[data-toggle=dropdown]",n=function(t){var n=e(t).on("click.dropdown.data-api",this.toggle);e("html").on("click.dropdown.data-api",function(){n.parent().removeClass("open")})};n.prototype={constructor:n,toggle:function(t){var n=e(this),s,o;if(n.is(".disabled, :disabled"))return;return s=i(n),o=s.hasClass("open"),r(),o||("ontouchstart"in document.documentElement&&e('