Browse Source

I should commit more frequently.

pull/1/head
Dashie der otter 3 years ago
parent
commit
a226ff211a
Signed by: Dashie <dashie@sigpipe.me> GPG Key ID: C2D57B325840B755
100 changed files with 26865 additions and 9 deletions
  1. 5
    0
      .gitignore
  2. 6
    0
      .gitmodules
  3. 15
    9
      README.md
  4. 33
    0
      adif.py
  5. 169
    0
      ahrl.py
  6. 40
    0
      config.py.sample
  7. 0
    0
      controllers/__init__.py
  8. 31
    0
      controllers/api.py
  9. 16
    0
      controllers/main.py
  10. 68
    0
      controllers/notes.py
  11. 381
    0
      controllers/qsos.py
  12. 159
    0
      controllers/tools.py
  13. 42
    0
      controllers/users.py
  14. 19
    0
      crons.py
  15. 4190
    0
      dbseed.py
  16. 142
    0
      forms.py
  17. 46
    0
      libjambon.py
  18. 1
    0
      migrations/README
  19. 45
    0
      migrations/alembic.ini
  20. 87
    0
      migrations/env.py
  21. 22
    0
      migrations/script.py.mako
  22. 328
    0
      migrations/versions/a8a7c1e2e3a_.py
  23. 394
    0
      models.py
  24. 587
    0
      static/css/bootstrap-theme.css
  25. 1
    0
      static/css/bootstrap-theme.css.map
  26. 6
    0
      static/css/bootstrap-theme.min.css
  27. 1
    0
      static/css/bootstrap-theme.min.css.map
  28. 6760
    0
      static/css/bootstrap.css
  29. 1
    0
      static/css/bootstrap.css.map
  30. 6
    0
      static/css/bootstrap.min.css
  31. 1
    0
      static/css/bootstrap.min.css.map
  32. BIN
      static/css/chosen-sprite.png
  33. BIN
      static/css/chosen-sprite@2x.png
  34. 450
    0
      static/css/chosen.css
  35. 3
    0
      static/css/chosen.min.css
  36. 239
    0
      static/css/font-awesome.css
  37. 4
    0
      static/css/font-awesome.min.css
  38. 15
    0
      static/css/ie10-viewport-bug-workaround.css
  39. BIN
      static/css/images/marker-icon-2x.png
  40. BIN
      static/css/images/markers-matte.png
  41. BIN
      static/css/images/markers-matte@2x.png
  42. BIN
      static/css/images/markers-plain.png
  43. BIN
      static/css/images/markers-shadow.png
  44. BIN
      static/css/images/markers-shadow@2x.png
  45. BIN
      static/css/images/markers-soft.png
  46. BIN
      static/css/images/markers-soft@2x.png
  47. BIN
      static/css/images/ui-anim_basic_16x16.gif
  48. BIN
      static/css/images/ui-bg_diagonals-thick_18_b81900_40x40.png
  49. BIN
      static/css/images/ui-bg_diagonals-thick_20_666666_40x40.png
  50. BIN
      static/css/images/ui-bg_flat_0_888888_40x100.png
  51. BIN
      static/css/images/ui-bg_flat_0_aaaaaa_40x100.png
  52. BIN
      static/css/images/ui-bg_flat_10_000000_40x100.png
  53. BIN
      static/css/images/ui-bg_flat_75_ffffff_40x100.png
  54. BIN
      static/css/images/ui-bg_glass_100_f6f6f6_1x400.png
  55. BIN
      static/css/images/ui-bg_glass_100_fdf5ce_1x400.png
  56. BIN
      static/css/images/ui-bg_glass_25_e1f0f5_1x400.png
  57. BIN
      static/css/images/ui-bg_glass_55_444444_1x400.png
  58. BIN
      static/css/images/ui-bg_glass_65_ffffff_1x400.png
  59. BIN
      static/css/images/ui-bg_glass_75_dadada_1x400.png
  60. BIN
      static/css/images/ui-bg_gloss-wave_35_f6a828_500x100.png
  61. BIN
      static/css/images/ui-bg_highlight-soft_100_eeeeee_1x100.png
  62. BIN
      static/css/images/ui-bg_highlight-soft_75_cccccc_1x100.png
  63. BIN
      static/css/images/ui-bg_highlight-soft_75_ffe45c_1x100.png
  64. BIN
      static/css/images/ui-bg_inset-soft_95_fef1ec_1x100.png
  65. BIN
      static/css/images/ui-icons_222222_256x240.png
  66. BIN
      static/css/images/ui-icons_228ef1_256x240.png
  67. BIN
      static/css/images/ui-icons_309bbf_256x240.png
  68. BIN
      static/css/images/ui-icons_454545_256x240.png
  69. BIN
      static/css/images/ui-icons_bf3030_256x240.png
  70. BIN
      static/css/images/ui-icons_ef8c08_256x240.png
  71. BIN
      static/css/images/ui-icons_ffd27a_256x240.png
  72. BIN
      static/css/images/ui-icons_ffffff_256x240.png
  73. 1225
    0
      static/css/jquery-ui.css
  74. 7
    0
      static/css/jquery-ui.min.css
  75. 833
    0
      static/css/jquery-ui.structure.css
  76. 5
    0
      static/css/jquery-ui.structure.min.css
  77. 410
    0
      static/css/jquery-ui.theme.css
  78. 5
    0
      static/css/jquery-ui.theme.min.css
  79. 124
    0
      static/css/leaflet.awesome-markers.css
  80. 479
    0
      static/css/leaflet.css
  81. 2
    0
      static/css/morris.css
  82. 62
    0
      static/css/pygments.css
  83. 78
    0
      static/css/style.css
  84. 180
    0
      static/css/timeline.css
  85. 18
    0
      static/datatables/Contributing.md
  86. 53
    0
      static/datatables/Readme.md
  87. 762
    0
      static/datatables/examples/advanced_init/column_render.html
  88. 755
    0
      static/datatables/examples/advanced_init/complex_header.html
  89. 746
    0
      static/datatables/examples/advanced_init/defaults.html
  90. 755
    0
      static/datatables/examples/advanced_init/dom_multiple_elements.html
  91. 756
    0
      static/datatables/examples/advanced_init/dom_toolbar.html
  92. 761
    0
      static/datatables/examples/advanced_init/dt_events.html
  93. 745
    0
      static/datatables/examples/advanced_init/events_live.html
  94. 743
    0
      static/datatables/examples/advanced_init/footer_callback.html
  95. 745
    0
      static/datatables/examples/advanced_init/html5-data-attributes.html
  96. 744
    0
      static/datatables/examples/advanced_init/html5-data-options.html
  97. 73
    0
      static/datatables/examples/advanced_init/index.html
  98. 742
    0
      static/datatables/examples/advanced_init/language_file.html
  99. 744
    0
      static/datatables/examples/advanced_init/length_menu.html
  100. 0
    0
      static/datatables/examples/advanced_init/object_dom_read.html

+ 5
- 0
.gitignore View File

@@ -0,0 +1,5 @@
*.pyc
config.py
TODO.org
.idea
*.db

+ 6
- 0
.gitmodules View File

@@ -0,0 +1,6 @@
[submodule "texttable"]
path = texttable
url = https://github.com/bufordtaylor/python-texttable.git
[submodule "dxcc"]
path = dxcc
url = https://github.com/rhaamo/dxcc.git

+ 15
- 9
README.md View File

@@ -1,34 +1,40 @@
Another Ham Radio Log
=====================



# Installation
Install a BDD (sqlite, mysql, postgresql)
Makes sure that encoding is/will be in UNICODE/UTF-8
git clone http://dev.sigpipe.me/dashie/ahrl
git clone http://dev.sigpipe.me/DashieHam/ahrl
cd ahrl
git submodule init
git submodule update
pip install --requirement requirements.txt
pip install --requirement requirements.txt # if present
cp config.py.sample config.py
$EDITOR config.py
python ahrl.py db upgrade
python ahrl.py db_seed
python ahrl.py mkdirs
python ahrl.py runserver # or whatever gunicorn whatever stuff

# Gunicorn
gunicorn -w 2 -b 127.0.0.1:8000 --error-logfile=errors.log --access-logfile=access.log --chdir=$PWD ahrl:app

# Crontabs
TBD
List of cron target availables.
Makes sure to run them under the user which runs ahrl and virtualenv if you use it.
Commands:
- python ahrl.py cron_update_qsos_countries # Update all QSOs if missing a Country/DXCC entry by using ClubLog

# Licensing
- MIT License

# Fork from
- This is majoritary a fork of https://github.com/magicbug/Cloudlog by 2E0SQL
- Thanks to him for all his work on CloudLog which inspired me (and from which I reused some things)

# TODO
- Unicorns
- More unicorns
- Even more unicorns !
- eQSL integration
- HAMQTH integration
- QSO edit
- More statistics
- Radio/CAT edit, add you own rig and link them when QSOing


+ 33
- 0
adif.py View File

@@ -0,0 +1,33 @@
import re
import datetime

# Comes from https://web.bxhome.org/content/adifpy

ADIF_REC_RE = re.compile(r'<(.*?):(\d+).*?>([^<\t\f\v]+)')


def parse(s):
raw = re.split('<eor>|<eoh>(?i)', s)
logbook = []
for record in raw[1:-1]:
qso = {}
tags = ADIF_REC_RE.findall(record)
for tag in tags:
qso[tag[0].lower()] = tag[2][:int(tag[1])]
logbook.append(qso)
return logbook


def save(fn, data):
fh = open(fn, 'w')
fh.write('ADIF.PY by OK4BX\nhttp://web.bxhome.org\n<EOH>\n')
for qso in data:
for key in sorted(qso):
value = qso[key]
fh.write('<%s:%i>%s ' % (key.upper(), len(value), value))
fh.write('<EOR>\n')
fh.close()


def conv_datetime(adi_date, adi_time):
return datetime.datetime.strptime(adi_date+adi_time.ljust(6, "0"), "%Y%m%d%H%M%S")

+ 169
- 0
ahrl.py View File

@@ -0,0 +1,169 @@
from pprint import pprint as pp

from flask import Flask, render_template, g, send_from_directory, jsonify
from flask.ext.security import Security, current_user
from flask.ext.script import Manager
from flask.ext.migrate import Migrate, MigrateCommand
from flask.ext.mail import Mail
from flask_bootstrap import Bootstrap
from flask_debugtoolbar import DebugToolbarExtension

from models import db, user_datastore

from controllers.main import bp_main
from controllers.users import bp_users
from controllers.notes import bp_notes
from controllers.qsos import bp_qsos
from controllers.tools import bp_tools
from crons import update_qsos_without_countries

import texttable
from dbseed import make_db_seed
from forms import ExtendedRegisterForm

import os
import subprocess

from utils import dt_utc_to_user_tz, InvalidUsage, show_date_no_offset

import logging
from logging.handlers import RotatingFileHandler

__VERSION__ = "0.0.1"

# App Configuration
app = Flask(__name__)
Bootstrap(app)

app.jinja_env.add_extension('jinja2.ext.with_')
app.jinja_env.filters['localize'] = dt_utc_to_user_tz
app.jinja_env.filters['show_date_no_offset'] = show_date_no_offset

app.config.from_pyfile("config.py")

# Logging
if not app.debug:
formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')
file_handler = RotatingFileHandler("%s/errors_app.log" % os.getcwd(), 'a', 1000000, 1)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
app.logger.addHandler(file_handler)

toolbar = DebugToolbarExtension(app)
mail = Mail(app)

db.init_app(app)

migrate = Migrate(app, db)

manager = Manager(app)

# Setup Flask-Security
security = Security(app, user_datastore,
register_form=ExtendedRegisterForm)

git_version = ""
gitpath = os.path.join(os.getcwd(), ".git")
if os.path.isdir(gitpath):
git_version = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'])
if git_version:
git_version = git_version.strip()


@app.before_request
def before_request():
g.cfg = {
'AHRL_VERSION': "{0} ({1})".format(__VERSION__, git_version),
}
g.current_user = current_user


@app.errorhandler(InvalidUsage)
def handle_invalid_usage(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
return response

app.register_blueprint(bp_main)
app.register_blueprint(bp_users)
app.register_blueprint(bp_notes)
app.register_blueprint(bp_qsos)
app.register_blueprint(bp_tools)


# Used in development
@app.route('/uploads/<path:stuff>', methods=['GET'])
def get_uploads_stuff(stuff):
print("Get {0} from {1}".format(stuff, app.config['UPLOADS_DEFAULT_DEST']))
return send_from_directory(['UPLOADS_DEFAULT_DEST'], stuff, as_attachment=False)


@app.errorhandler(404)
def page_not_found(e):
pcfg = {"title": "Whoops, something failed.",
"error": 404, "message": "Page not found", "e": e}
return render_template('error_page.jinja2', pcfg=pcfg), 404


@app.errorhandler(403)
def err_forbidden(e):
pcfg = {"title": "Whoops, something failed.",
"error": 403, "message": "Access forbidden", "e": e}
return render_template('error_page.jinja2', pcfg=pcfg), 403


@app.errorhandler(410)
def err_gone(e):
pcfg = {"title": "Whoops, something failed.",
"error": 410, "message": "Gone", "e": e}
return render_template('error_page.jinja2', pcfg=pcfg), 410


if not app.debug:
@app.errorhandler(500)
def err_failed(e):
pcfg = {"title": "Whoops, something failed.", "error": 500, "message": "Something is broken", "e": e}
return render_template('error_page.jinja2', pcfg=pcfg), 500


# Other commands
@manager.command
def dump_routes():
"""Dump all routes of defined app"""
table = texttable.Texttable()
table.set_deco(texttable.Texttable().HEADER)
table.set_cols_dtype(['t', 't', 't'])
table.set_cols_align(["l", "l", "l"])
table.set_cols_width([60, 30, 90])

table.add_rows([["Prefix", "Verb", "URI Pattern"]])

for rule in sorted(app.url_map.iter_rules(), key=lambda x: x.match_compare_key()):
methods = ','.join(rule.methods)
table.add_row([rule.endpoint, methods, rule])

print(table.draw())


@manager.command
def config():
"""Dump config"""
pp(app.config)


@manager.command
def db_seed():
"""Seed database with default content"""
make_db_seed(db)


@manager.command
def cron_update_qsos_countries():
"""Update QSOs with empty country"""
update_qsos_without_countries(db)


manager.add_command('db', MigrateCommand)

if __name__ == '__main__':
manager.run()

+ 40
- 0
config.py.sample View File

@@ -0,0 +1,40 @@
DEBUG = True

SECRET_KEY = 'bleh23r29-r2-9jwehoiqrpj'
#SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://dashie@localhost/ahrl'
# SQLALCHEMY_DATABASE_URI = 'postgresql+pg8000://dashie@localhost/ahrl'
SQLALCHEMY_DATABASE_URI = 'sqlite:///ahrl.db'
SQLALCHEMY_ECHO=False

SECURITY_CONFIRMABLE = False
SECURITY_REGISTERABLE = False # deactivate registration
SECURITY_RECOVERABLE = True
SECURITY_TRACKABLE = False
SECURITY_CHANGEABLE = True
SECURITY_PASSWORD_HASH = 'bcrypt'
SECURITY_PASSWORD_SALT = 'omgponies'
# SECURITY_URL_PREFIX = '/sec'

SECURITY_SEND_REGISTER_EMAIL = False
SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False
SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = False

BOOTSTRAP_USE_MINIFIED = True
BOOTSTRAP_SERVE_LOCAL = True
BOOTSTRAP_CDN_FORCE_SSL = True
BOOTSTRAP_QUERYSTRING_REVVING = True

DEBUG_TB_PROFILER_ENABLED = True
DEBUG_TB_INTERCEPT_REDIRECTS = False

# Paginations

CLUBLOG_API_KEY=""

LOOKUP_METHOD="hamqth"

QRZ_USERNAME=""
QRZ_PASSWORD=""

HAMQTH_USERNAME=""
HAMQTH_PASSWORD=""

+ 0
- 0
controllers/__init__.py View File


+ 31
- 0
controllers/api.py View File

@@ -0,0 +1,31 @@
from flask import Blueprint, redirect, url_for, abort
from flask.ext.security import login_required, current_user
from models import db, Apitoken
from utils import generate_uniques_apitoken

bp_api = Blueprint('bp_api', __name__)


@bp_api.route('/apitoken/new')
@login_required
def apitoken_new():
apitoken = generate_uniques_apitoken()
if not apitoken:
return abort(500)

a = Apitoken()
a.user_id = current_user.id
a.token = apitoken["token"]
a.secret = apitoken["secret"]
db.session.add(a)
db.session.commit()
return redirect(url_for('bp_users.user_profile'))


@bp_api.route('/apitoken/<string:apit>/del')
@login_required
def apitoken_del(apit):
apitoken = Apitoken.query.get_or_404(apit)
db.session.delete(apitoken)
db.session.commit()
return redirect(url_for('bp_users.user_profile'))

+ 16
- 0
controllers/main.py View File

@@ -0,0 +1,16 @@
from flask import Blueprint, render_template

bp_main = Blueprint('bp_main', __name__)


# Show public logbooks
@bp_main.route('/')
def home():
pcfg = {"title": "AHRL - Another Ham Radio Log"}
return render_template('home.jinja2', pcfg=pcfg)


@bp_main.route('/about')
def about():
pcfg = {"title": "About AHRL - Another Ham Radio Log"}
return render_template('about.jinja2', pcfg=pcfg)

+ 68
- 0
controllers/notes.py View File

@@ -0,0 +1,68 @@
from flask import Blueprint, render_template, request, redirect, url_for
from flask.ext.security import login_required, current_user
from models import db, Note
from forms import NoteForm
from utils import check_default_profile

bp_notes = Blueprint('bp_notes', __name__)


@bp_notes.route('/notes', methods=['GET'])
@login_required
@check_default_profile
def notes():
pcfg = {"title": "My notes"}
_notes = Note.query.all()
return render_template('notes/view.jinja2', pcfg=pcfg, notes=_notes)


@bp_notes.route('/notes/<int:note_id>/edit', methods=['GET', 'POST'])
@login_required
@check_default_profile
def edit(note_id):
pcfg = {"title": "Edit my notes"}
a = Note.query.get_or_404(note_id)

form = NoteForm(request.form, a)

if form.validate_on_submit():
a.title = form.title.data
a.cat = form.cat.data
a.note = form.note.data

db.session.commit()
return redirect(url_for('bp_notes.notes'))

return render_template('notes/edit.jinja2', pcfg=pcfg, form=form, note=a, note_id=note_id)


@bp_notes.route('/notes/new', methods=['GET', 'POST'])
@login_required
@check_default_profile
def new():
pcfg = {"title": "New note"}

form = NoteForm()

if form.validate_on_submit():
a = Note()
a.title = form.title.data
a.cat = form.cat.data
a.note = form.note.data
a.user_id = current_user.id

db.session.add(a)
db.session.commit()
return redirect(url_for('bp_notes.notes'))

return render_template('notes/new.jinja2', pcfg=pcfg, form=form)


@bp_notes.route('/notes/<int:note_id>/delete', methods=['GET', 'DELETE', 'PUT'])
@login_required
@check_default_profile
def delete(note_id):
note = Note.query.get_or_404(note_id)
db.session.delete(note)
db.session.commit()
return redirect(url_for('bp_notes.notes'))

+ 381
- 0
controllers/qsos.py View File

@@ -0,0 +1,381 @@
from flask import Blueprint, render_template, request, redirect, url_for, Response, json, abort
from flask.ext.security import login_required, current_user
from models import db, User, Log
from forms import QsoForm
import pytz
import datetime
from libjambon import band_to_frequency, geo_bearing_star
from utils import InvalidUsage, dt_utc_to_user_tz, check_default_profile, get_dxcc_from_clublog
from geohelper import distance, bearing
from libqth import is_valid_qth, qth_to_coords
from calendar import monthrange

bp_qsos = Blueprint('bp_qsos', __name__)


@bp_qsos.route('/logbook/<string:username>', methods=['GET'])
@check_default_profile
def logbook(username):
user = User.query.filter(User.name == username).first()
if not user:
return abort(404)
pcfg = {"title": "{0}'s ({1}) logbook".format(user.name, user.callsign)}
qsos = Log.query.filter(User.id == user.id).all()
uqth = user.qth_to_coords()

d = datetime.datetime.utcnow()
mr_m = monthrange(d.year, d.month)
mr_y = monthrange(d.year, 12)

d_month_start = datetime.datetime(d.year, d.month, 0o1, 00, 00, 00, tzinfo=pytz.timezone('UTC'))
d_month_end = datetime.datetime(d.year, d.month, mr_m[1], 23, 59, 59, tzinfo=pytz.timezone('UTC'))
d_year_start = datetime.datetime(d.year, 0o1, 0o1, 00, 00, 00, tzinfo=pytz.timezone('UTC'))
d_year_end = datetime.datetime(d.year, 12, mr_y[1], 23, 59, 59, tzinfo=pytz.timezone('UTC'))
cntry_worked = db.session.query(Log.country).filter(Log.user_id == user.id).distinct(Log.country).count()

stats = {
'qsos': {
'total': Log.query.filter(Log.user_id == user.id).count(),
'month': Log.query.filter(Log.user_id == user.id,
Log.time_on.between(d_month_start, d_month_end)).count(),
'year': Log.query.filter(Log.user_id == user.id,
Log.time_on.between(d_year_start, d_year_end)).count()
},
'countries': {
'worked': cntry_worked,
'needed': 340 - cntry_worked
},
'qsl': {
'sent': Log.query.filter(Log.user_id == user.id, Log.qsl_sent == 'Y').count(),
'received': Log.query.filter(Log.user_id == user.id, Log.qsl_rcvd == 'Y').count(),
'requested': Log.query.filter(Log.user_id == user.id, Log.qsl_sent == 'R').count()
}
}

return render_template('qsos/logbook.jinja2', pcfg=pcfg, logbook=qsos, user=user, uqth=uqth, stats=stats)


@bp_qsos.route('/qsos/new/<string:method>', methods=['GET', 'POST'])
@login_required
@check_default_profile
def new(method):
if method not in ['live', 'manual']:
return redirect(url_for('bp_qsos.logbook', user_id=current_user.id))

pcfg = {"title": "New QSO"}

form = QsoForm()

if form.validate_on_submit():
a = Log()
# We get from user input a date with timezone offset (we asume that)
# We store in database in UTC offset, so first convert to DT then makes aware
# of the user timezone, and then convert to UTC
date = form.date.data.strftime('%d-%m-%Y')
time = form.time.data.strftime('%H:%M:%S')
date_wo_tz = datetime.datetime.strptime("{0} {1}".format(date, time),
"%d-%m-%Y %H:%M:%S")
date_w_tz = pytz.timezone(current_user.timezone).localize(date_wo_tz)

a.time_on = date_w_tz.astimezone(pytz.timezone('UTC'))
a.time_off = date_w_tz.astimezone(pytz.timezone('UTC'))
a.call = form.callsign.data
a.freq = form.frequency.data
a.rst_rcvd = form.rst_r.data
a.rst_sent = form.rst_s.data
a.name = form.name.data
a.comment = form.comment.data
a.sat_name = form.sat_name.data
a.sat_mode = form.sat_mode.data
a.gridsquare = form.locator.data
a.country = form.country.data
a.qsl_sent = form.qsl_sent.raw_data[0]
a.qsl_sent_via = form.qsl_method.raw_data[0]
a.qsl_via = form.qsl_via.data
a.operator = current_user.callsign
a.owner_callsign = current_user.callsign
a.station_callsign = current_user.callsign
a.qth = form.location.data
a.prop_mode = form.propagation.raw_data[0]
a.iota = form.iota.data
a.my_gridsquare = current_user.locator
a.dxcc = form.dxcc_id.data
a.cqz = form.cqz.data

if current_user.swl:
a.swl = 'Y'
else:
a.swl = 'N'

a.distance = 0 # ??
a.freq_rx = 0 # ??

a.user_id = current_user.id
a.my_rig = form.radio.raw_data[0] # TODO relation in model
a.band_id = form.band.raw_data[0]
a.mode_id = form.band.raw_data[0]

db.session.add(a)
db.session.commit()
return redirect(url_for('bp_qsos.new', method=method))

qsos = Log.query.filter(User.id == current_user.id).limit(16).all()

return render_template('qsos/new.jinja2', pcfg=pcfg, form=form, logbook=qsos, method=method)


@bp_qsos.route('/qsos/<int:qso_id>/delete', methods=['GET', 'DELETE', 'PUT'])
@login_required
@check_default_profile
def delete(qso_id):
qso = Log.query.get_or_404(qso_id)
db.session.delete(qso)
db.session.commit()
return redirect(url_for('bp_qsos.logbook', username=qso.user.name))


@bp_qsos.route('/qsos/lib/jambon/band_to_freq', methods=['GET'])
@login_required
def lib_jambon_band_to_freq():
band = request.args.get('band')
mode = request.args.get('mode')

if not band or not mode:
raise InvalidUsage('Missing band or mode', status_code=400)

response = {'status': 'ok', 'band': band, 'mode': mode, 'frequency': band_to_frequency(int(band), int(mode))}
return Response(json.dumps(response), mimetype='application/json')


@bp_qsos.route('/qsos/lib/geo/bearing', methods=['GET'])
@login_required
def lib_geo_bearing():
locator_qso = request.args.get('locator_qso')
locator_user = request.args.get('locator_user')

if not locator_qso or not locator_user:
raise InvalidUsage('Missing locator_qso or locator_user', status_code=400)

if not is_valid_qth(locator_user, 6) or not is_valid_qth(locator_qso, 6):
raise InvalidUsage('One of the supplied QTH is not valid', status_code=400)

_f = qth_to_coords(locator_user, 6) # precision, latitude, longitude
_t = qth_to_coords(locator_qso, 6) # precision, latitude, longitude

response = {'status': 'ok',
'locator_qso': locator_qso,
'locator_user': locator_user,
'distance': distance.haversine_km(_f['latitude'],
_f['longitude'],
_t['latitude'],
_t['longitude']),
'unit': 'km',
'bearing': bearing.initial_compass_bearing(_f['latitude'],
_f['longitude'],
_t['latitude'],
_t['longitude'])}
response['bearing_star'] = geo_bearing_star(response['bearing'])

return Response(json.dumps(response), mimetype='application/json')


@bp_qsos.route('/qsos/lib/clublog/dxcc', methods=['GET'])
@login_required
def lib_clublog_dxcc():
callsign = request.args.get('callsign')

if not callsign:
raise InvalidUsage('Missing callsign', status_code=400)

dxcc = get_dxcc_from_clublog(callsign)
if not dxcc:
raise InvalidUsage('Error while getting infos from clublog', status_code=500)

response = {'status': 'ok'}
response.update(dxcc)

return Response(json.dumps(response), mimetype='application/json')


@bp_qsos.route('/logbook/<string:username>/geojson', methods=['GET'])
@login_required
def logbook_geojson(username):
if not username:
raise InvalidUsage('Missing username', status_code=400)

user = User.query.filter(User.name == username).first()
if not user:
raise InvalidUsage('User not found', status_code=404)

logs = Log.query.filter(User.id == user.id).all()

if not is_valid_qth(user.locator, 6):
raise InvalidUsage('QTH is not valid', status_code=400)
_u = qth_to_coords(user.locator, 6) # precision, latitude, longitude

j = [{
"type": "Feature",
"properties": {
"name": user.cutename(),
"callsign": user.callsign,
"own": True,
"icon": "home"
},
"geometry": {
"type": "Point",
"coordinates": [_u['longitude'], _u['latitude']]
}
}]

for log in logs:
if log.gridsquare:
if not is_valid_qth(log.gridsquare, 6):
raise InvalidUsage('QTH is not valid', status_code=400)
_f = qth_to_coords(log.gridsquare, 6) # precision, latitude, longitude
else:
_f = log.country_grid_coords()
if not _f:
continue # No grid at all ? Skit ip

f = {
"type": "Feature",
"properties": {
"name": log.cutename(),
"callsign": log.call,
"date": dt_utc_to_user_tz(log.time_on, user=user),
"band": log.band.name,
"mode": log.mode.mode,
"icon": "qso"
},
"geometry": {
"type": "Point",
"coordinates": [_f['longitude'], _f['latitude']]
}
}
j.append(f)

return Response(json.dumps(j), mimetype='application/json')


@bp_qsos.route('/logbook/qso/<int:qso_id>/geojson', methods=['GET'])
@login_required
def logbook_qso_geojson(qso_id):
if not qso_id:
raise InvalidUsage('Missing qso_id', status_code=400)

qso = Log.query.filter(Log.id == qso_id).first()
if not qso:
raise InvalidUsage('Qso not found', status_code=404)

if qso.gridsquare:
if not is_valid_qth(qso.gridsquare, 6):
raise InvalidUsage('QTH is not valid', status_code=400)
_f = qth_to_coords(qso.gridsquare, 6) # precision, latitude, longitude
else:
_f = qso.country_grid_coords()
if not _f:
raise InvalidUsage('No valid QTH', status_code=404)

f = [{
"type": "Feature",
"properties": {
"name": qso.cutename(),
"callsign": qso.call,
"date": dt_utc_to_user_tz(qso.time_on, user=qso.user),
"band": qso.band.name,
"mode": qso.mode.mode,
"icon": 'home'
},
"geometry": {
"type": "Point",
"coordinates": [_f['longitude'], _f['latitude']]
}
}]

if not is_valid_qth(qso.user.locator, 6):
raise InvalidUsage('QTH is not valid', status_code=400)
_u = qth_to_coords(qso.user.locator, 6) # precision, latitude, longitude
g = {
"type": "Feature",
"properties": {
"name": qso.user.cutename(),
"callsign": qso.user.callsign,
"own": True,
"icon": 'qso'
},
"geometry": {
"type": "Point",
"coordinates": [_u['longitude'], _u['latitude']]
}
}
f.append(g)

f.append({
"type": "LineString",
"coordinates": [[_f['longitude'], _f['latitude']], [_u['longitude'], _u['latitude']]]
})

return Response(json.dumps(f), mimetype='application/json')


@bp_qsos.route('/logbook/qso/<int:qso_id>/modal', methods=['GET'])
def single_qso_modal(qso_id):
qso = Log.query.get_or_404(qso_id)
if not qso.gridsquare:
qso_gs = qso.country_grid()
else:
qso_gs = qso.gridsquare

if not qso_gs or not qso.user.locator:
raise InvalidUsage('Missing qso.gridsquare or qso.user.locator', status_code=400)

if not is_valid_qth(qso.user.locator, 6) or not is_valid_qth(qso_gs, 6):
raise InvalidUsage('One of the supplied QTH is not valid', status_code=400)

_f = qth_to_coords(qso.user.locator, 6) # precision, latitude, longitude
_t = qth_to_coords(qso_gs, 6) # precision, latitude, longitude

qso_distance = distance.haversine_km(_f['latitude'],
_f['longitude'],
_t['latitude'],
_t['longitude'])

qso_bearing = bearing.initial_compass_bearing(_f['latitude'],
_f['longitude'],
_t['latitude'],
_t['longitude'])
qso_bearing_star = geo_bearing_star(qso_bearing)

return render_template('qsos/_single_qso_modal.jinja2', qso=qso, qso_distance=qso_distance, qso_bearing=qso_bearing,
qso_bearing_star=qso_bearing_star, qso_distance_unit='Km')


@bp_qsos.route('/logbook/stats/<string:username>', methods=['GET'])
@check_default_profile
def logbook_stats(username):
user = User.query.filter(User.name == username).first()
if not user:
return abort(404)
pcfg = {'title': 'Stats'}

stats_months = []

dt = datetime.datetime.utcnow()

for y in [dt.year, dt.year - 1]:
stats_y = []
for i in range(1, 13):
mr = monthrange(y, i)
d_month_start = datetime.datetime(y, i, 0o1, 00, 00, 00, tzinfo=pytz.timezone('UTC'))
d_month_end = datetime.datetime(y, i, mr[1], 23, 59, 59, tzinfo=pytz.timezone('UTC'))
stats_y.append({
'count': Log.query.filter(Log.user_id == user.id,
Log.time_on.between(d_month_start, d_month_end)).count(),
'month': i,
'year': y,
'label': 'QSOs',
'date': "{0}-{1}".format(y, i)
})
stats_months.append(stats_y)

return render_template('qsos/stats.jinja2', pcfg=pcfg, stats_months=json.dumps(stats_months))

+ 159
- 0
controllers/tools.py View File

@@ -0,0 +1,159 @@
from flask import Blueprint, render_template, redirect, url_for, stream_with_context, Response, flash
from flask.ext.security import login_required, current_user
from models import db, Log, Mode, Band
from utils import check_default_profile, ADIF_FIELDS
from adif import parse as adif_parser
from forms import AdifParse
from werkzeug.utils import secure_filename
import datetime
import pytz

bp_tools = Blueprint('bp_tools', __name__)


@bp_tools.route('/tools/adif/import', methods=['GET'])
@login_required
@check_default_profile
def adif_import():
pcfg = {"title": "Import ADIF"}
form = AdifParse()
return render_template('tools/adif_import.jinja2', pcfg=pcfg, form=form)


@bp_tools.route('/tools/adif/import', methods=['POST'])
@login_required
@check_default_profile
def adif_import_file():
pcfg = {"title": "Import ADIF"}
form = AdifParse()
if form.validate_on_submit():
filename = secure_filename(form.adif_file.data.filename)
files = form.adif_file.raw_data[0].stream.read()
form.adif_file.raw_data[0].close()

parsed_adif = adif_parser(files)
count = 0
duplicates = 0

for log in parsed_adif:
# First check if duplicate
_date = "{0} {1}".format(log['qso_date'], log['time_on'])
_date_wo_tz = datetime.datetime.strptime(_date, "%Y%m%d %H%M%S")
dl = Log.query.filter(Log.user_id == current_user.id,
Log.call == log['call'],
Log.time_on == _date_wo_tz)
if dl:
duplicates += 1
continue # duplicate found, skip record

l = Log()
for key in ADIF_FIELDS:
if key not in log:
continue

setattr(l, key, log[key])
# other fields to manage specifically
if 'class' in log:
l.klass = log['class']
if 'band' in log:
band = Band.query.filter(Band.name == log['band'],
Band.start.is_(None),
Band.modes.is_(None)).first()
if not band:
band = Mode.query.filter(Band.name == 'SSB',
Band.start.is_(None),
Band.modes.is_(None)).first()
l.comment += "\r\nBand automatically set to 40m because not found in ADIF"
l.band_id = band.id
if 'freq' in log:
l.freq = int(float(log['freq']) * 1000000) # ADIF stores in MHz, we store in Hertz
if 'freq_rx' in log:
l.freq_rx = int(float(log['freq_rx']) * 1000000) # Same as freq
if 'mode' in log:
mode = Mode.query.filter(Mode.mode == log['mode']).first()
if not mode:
mode = Mode.query.filter(Mode.mode == 'SSB').first()
l.comment += "\r\nMode automatically set to SSB because not found in ADIF"
l.mode_id = mode.id
# Reminder : ADIF is in UTC, we store in UTC, no TZ conversion necessary
if 'qso_date' in log and 'time_on':
date = "{0} {1}".format(log['qso_date'], log['time_on'])
date_wo_tz = datetime.datetime.strptime(date, "%Y%m%d %H%M%S")
date_w_tz = pytz.timezone(current_user.timezone).localize(date_wo_tz)
l.time_on = date_w_tz.astimezone(pytz.timezone('UTC'))
l.time_off = date_w_tz.astimezone(pytz.timezone('UTC'))
else:
date_w_tz = datetime.datetime.utcnow()
l.time_on = date_w_tz.astimezone(pytz.timezone('UTC'))
l.time_off = date_w_tz.astimezone(pytz.timezone('UTC'))
l.comment += "\r\nDate set to the import date because not found in ADIF"
l.user = current_user # oops dont miss it

db.session.add(l)
count += 1 # One more in the stack
db.session.commit()

flash('Imported {0} ({1} duplicates) QSOs from {2}'.format(count, duplicates, filename), 'info')
else:
return render_template('tools/adif_import.jinja2', pcfg=pcfg, form=form, flash='Error with the file')

return redirect(url_for('bp_qsos.logbook', username=current_user.name))


@bp_tools.route('/tools/adif/export', methods=['GET'])
@login_required
@check_default_profile
def adif_export():
pcfg = {"title": "Export ADIF"}
return render_template('tools/adif_export.jinja2', pcfg=pcfg)


@bp_tools.route('/tools/adif/export/dl', methods=['GET'])
@login_required
def adif_export_dl():
logs = current_user.logs

def a(k, v):
return "<{0}:{1}>{2} ".format(str(k), len(str(v)), str(v))

def generate():
yield 'ADIF Export by AHRL\r\n'
yield '\r\n'
yield '<adif_ver:5>3.0.4\r\n'
yield '<programid:4>AHRL\r\n'
yield a('station_callsign', current_user.callsign) + '\r\n'
yield a('operator', current_user.callsign) + '\r\n'
yield '\r\n'
yield '<eoh>\r\n\r\n'

for log in logs:
counter = 0
for key in ADIF_FIELDS:
if counter == 3:
counter = 0
yield '\r\n'

value = getattr(log, key)
if value:
a(key, value)
counter += 1

yield '\r\n'
# Manual ones
if log.freq:
yield a('freq', log.freq / 1000000.0)
if log.freq_rx:
yield a('freq', log.freq_rx / 1000000.0)
if log.mode:
a('mode', log.mode.mode)
if log.time_on:
a('qso_date', log.time_on.strftime('%Y%m%d'))
a('time_on', log.time_on.strftime('%H%M%S'))
if log.klass:
a('class', log.klass)
if log.band:
a('band', log.band.name)
yield '\r\n<eor>\r\n\r\n'

return Response(stream_with_context(generate()), mimetype="text/plain",
headers={"Content-Disposition": "attachment;filename=qsos-{0}.adi".format(current_user.name)})

+ 42
- 0
controllers/users.py View File

@@ -0,0 +1,42 @@
from flask import Blueprint, render_template, request, redirect, url_for
from flask.ext.security import login_required, current_user
from models import db, User
from forms import UserProfileForm
from utils import check_default_profile

bp_users = Blueprint('bp_users', __name__)


@bp_users.route('/user', methods=['GET'])
@login_required
@check_default_profile
def profile():
pcfg = {"title": "My Profile"}
user = User.query.get_or_404(current_user.id)
return render_template('users/profile.jinja2', pcfg=pcfg, user=user)


@bp_users.route('/user/edit', methods=['GET', 'POST'])
@login_required
def edit():
pcfg = {"title": "Edit my profile"}
a = User.query.get_or_404(current_user.id)

form = UserProfileForm(request.form, a)

if form.validate_on_submit():
a.callsign = form.callsign.data
a.lastname = form.lastname.data
a.firstname = form.firstname.data
a.timezone = form.timezone.data
a.locator = form.locator.data
a.lotw_name = form.lotw_name.data
a.lotw_password = form.lotw_password.data
a.eqsl_name = form.eqsl_name.data
a.eqsl_password = form.eqsl_password.data
a.swl = form.swl.data

db.session.commit()
return redirect(url_for('bp_users.profile'))

return render_template('users/edit.jinja2', pcfg=pcfg, form=form, user=a)

+ 19
- 0
crons.py View File

@@ -0,0 +1,19 @@
from __future__ import print_function
from models import Log
from utils import get_dxcc_from_clublog


def update_qsos_without_countries(db):
updated = 0
logs = Log.query.filter(Log.country.is_(None) | Log.dxcc.is_(None) | Log.cqz.is_(None)).all()
for log in logs:
if not log.call:
continue
dxcc = get_dxcc_from_clublog(log.call)
log.dxcc = dxcc['DXCC']
log.cqz = dxcc['CQZ']
log.country = dxcc['Name']
log.cont = dxcc['Continent']
db.session.commit()
updated += 1
print("Updated {0} QSOs".format(updated))

+ 4190
- 0
dbseed.py
File diff suppressed because it is too large
View File


+ 142
- 0
forms.py View File

@@ -0,0 +1,142 @@
from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, SubmitField, TextAreaField, SelectField, IntegerField, \
HiddenField, BooleanField
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms.validators import DataRequired
from flask_security import RegisterForm
from models import db, User, Note, Cat, Mode, Band
from wtforms_alchemy import model_form_factory
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.ext.dateutil.fields import DateTimeField
from utils import dt_utc_to_user_tz
import datetime
import pytz

BaseModelForm = model_form_factory(Form)


class ModelForm(BaseModelForm):
@classmethod
def get_session(cls):
return db.session


class ExtendedRegisterForm(RegisterForm):
name = StringField('Name', [DataRequired()])


class UserProfileForm(ModelForm):
class Meta:
model = User

password = PasswordField('Password')
name = StringField('Name')
email = StringField('Email')

callsign = StringField('Callsign', [DataRequired()])
locator = StringField('Locator', [DataRequired()])
firstname = StringField('Firstname')
lastname = StringField('Lastname')
timezone = SelectField(choices=zip(pytz.all_timezones, pytz.all_timezones),
label='Timezone', default='UTC')
lotw_name = StringField('LoTW Username')
lotw_password = PasswordField('LoTW Password')
eqsl_name = StringField('eQSL.cc Username')
eqsl_password = PasswordField('eQSL.cc Password')

swl = BooleanField('Are you a SWL HAM ?')

submit = SubmitField('Update profile')


class NoteForm(ModelForm):
class Meta:
model = Note

cat = SelectField(choices=[
('General', 'General'),
('Antennas', 'Antennas'),
('Satellites', 'Satellites')], default=['General'], label='Category')
title = StringField('Title', [DataRequired()])
note = TextAreaField('Note', [DataRequired()])

submit = SubmitField('Sauver note')


def get_modes():
return Mode.query.all()


def get_bands():
return Band.query.filter(Band.modes.is_(None), Band.start.is_(None)).all()


def dflt_mode():
return Mode.query.filter(Mode.mode == 'SSB').first()


def dflt_band():
return Band.query.filter(Band.modes.is_(None), Band.start.is_(None), Band.name == '40m').first()


list_of_props = [['', ''], ['AUR', 'Aurora'], ['AUE', 'Aurora-E'], ['BS', 'Back scatter'],
['ECH', 'EchoLink'], ['EME', 'Earth-Moon-Earth'], ['ES', 'Sporadic E'],
['FAI', 'Field Aligned Irregularities'], ['F2', 'F2 Reflection'],
['INTERNET', 'Internet-assisted'], ['ION', 'Ionoscatter'], ['IRL', 'IRLP'],
['MS', 'Meteor scatter'], ['RPT', 'Terrestrial or atmospheric repeater or transponder'],
['RS', 'Rain scatter'], ['SAT', 'Satellite'], ['TEP', 'Tras-equatorial'],
['TR', 'Tropospheric ducting']]


def get_radios():
return Cat.query.all()


def foo_bar_baz_qux():
return dt_utc_to_user_tz(datetime.datetime.utcnow())


class QsoForm(Form):
date = DateTimeField('Date', default=datetime.datetime.utcnow, display_format='%d-%m-%Y')
time = DateTimeField('Time', default=foo_bar_baz_qux, display_format='%H:%M:%S')
callsign = StringField('Callsign', [DataRequired()])
mode = QuerySelectField(query_factory=get_modes, default=dflt_mode, label='Mode',
validators=[DataRequired()], get_label='mode')
band = QuerySelectField(query_factory=get_bands, default=dflt_band, label='Band',
validators=[DataRequired()], get_label='name')
rst_s = IntegerField('RST (S)', [DataRequired()], default=59)
rst_r = IntegerField('RST (R)', [DataRequired()], default=59)
name = StringField('Name')
location = StringField('Location')
locator = StringField('Locator') # TODO libqth is_valid_qth
comment = StringField('Comment')
country = StringField('Country', [DataRequired()])

# Hidden
dxcc_id = HiddenField(validators=[DataRequired()])
cqz = HiddenField(validators=[DataRequired()])

# Home
propagation = SelectField(choices=list_of_props, default='', label='Propagation Mode')
iota = StringField('IOTA', )

# Station
radio = QuerySelectField(query_factory=get_radios, allow_blank=True, label='Radio', get_label='radio')
frequency = IntegerField('Frequency', [DataRequired()])

# Satellite
sat_name = StringField('Sat name')
sat_mode = StringField('Sat mode')

# QSL
qsl_sent = SelectField('Sent', choices=[['N', 'No'], ['Y', 'Yes'], ['R', 'Requested']])
qsl_method = SelectField('Method', choices=[['', 'Method'], ['D', 'Direct'], ['B', 'Bureau']])
qsl_via = StringField('Via')

submit = SubmitField('Save')


class AdifParse(Form):
adif_file = FileField('File', [FileRequired(),
FileAllowed(['adi', 'adif'], 'Adif only !')])
submit = SubmitField('Import file')

+ 46
- 0
libjambon.py View File

@@ -0,0 +1,46 @@
import math
from models import Band, Mode


def band_to_frequency(band, mode):
if not isinstance(band, int) or not isinstance(mode, int):
raise TypeError('Sorry but only integer')

b = Band.query.filter(Band.id == band).first()
m = Mode.query.filter(Mode.id == mode).first()

if not b or not m:
return None

frequencies = Band.query.filter(Band.lower.is_(None),
Band.upper.is_(None),
Band.name == b.name,
Band.modes.contains(m.mode))

if frequencies.count() <= 0:
return None
else:
return frequencies.first().start


def frequency_to_band(frequency):
f = frequency
if type(f) == str:
f = int(frequency)

f_q = Band.query.filter(Band.start.is_(None),
Band.lower < f,
Band.upper > f).single()
return f_q.name


def geo_bearing_star(bearing):
dirs = ['N', 'E', 'S', 'W']

rounded = round(bearing / 22.5) % 16
if (rounded % 4) == 0:
_dir = dirs[int(rounded / 4)]
else:
_dir = dirs[int(2 * math.floor(((math.floor(rounded / 4) + 1) % 4) / 2))]
_dir += dirs[int(1 + 2 * math.floor(rounded / 8))]
return _dir

+ 1
- 0
migrations/README View File

@@ -0,0 +1 @@
Generic single-database configuration.

+ 45
- 0
migrations/alembic.ini View File

@@ -0,0 +1,45 @@
# A generic, single database configuration.

[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false


# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

+ 87
- 0
migrations/env.py View File

@@ -0,0 +1,87 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


def run_migrations_offline():
"""Run migrations in 'offline' mode.

This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.

Calls to context.execute() here emit the given string to the
script output.

"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
"""Run migrations in 'online' mode.

In this scenario we need to create an Engine
and associate a connection with the context.

"""

# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')

engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)

connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)

try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()

if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

+ 22
- 0
migrations/script.py.mako View File

@@ -0,0 +1,22 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision}
Create Date: ${create_date}

"""

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

def upgrade():
${upgrades if upgrades else "pass"}


def downgrade():
${downgrades if downgrades else "pass"}

+ 328
- 0
migrations/versions/a8a7c1e2e3a_.py View File

@@ -0,0 +1,328 @@
"""empty message

Revision ID: a8a7c1e2e3a
Revises: None
Create Date: 2016-05-05 17:01:42.609143

"""

# revision identifiers, used by Alembic.
revision = 'a8a7c1e2e3a'
down_revision = None

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('bands',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('modes', sa.String(length=255), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('lower', sa.Integer(), nullable=True),
sa.Column('upper', sa.Integer(), nullable=True),
sa.Column('start', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('cat',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('radio', sa.String(length=250), nullable=False),
sa.Column('frequency', sa.Integer(), nullable=False),
sa.Column('mode', sa.String(length=10), nullable=False),
sa.Column('timestamp', sa.DateTime(), server_default=sa.text(u'CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('config',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('lotw_download_url', sa.String(length=255), nullable=True),
sa.Column('lotw_upload_url', sa.String(length=255), nullable=True),
sa.Column('lotw_rcvd_mark', sa.String(length=255), nullable=True),
sa.Column('lotw_login_url', sa.String(length=255), nullable=True),
sa.Column('eqsl_download_url', sa.String(length=255), nullable=True),
sa.Column('eqsl_rcvd_mark', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('contest_template',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('band_160', sa.String(length=20), nullable=False),
sa.Column('band_80', sa.String(length=20), nullable=False),
sa.Column('band_40', sa.String(length=20), nullable=False),
sa.Column('band_20', sa.String(length=20), nullable=False),
sa.Column('band_15', sa.String(length=20), nullable=False),
sa.Column('band_10', sa.String(length=20), nullable=False),
sa.Column('band_6m', sa.String(length=20), nullable=False),
sa.Column('band_4m', sa.String(length=20), nullable=False),
sa.Column('band_2m', sa.String(length=20), nullable=False),
sa.Column('band_70cm', sa.String(length=20), nullable=False),
sa.Column('band_23cm', sa.String(length=20), nullable=False),
sa.Column('mode_ssb', sa.String(length=20), nullable=False),
sa.Column('mode_cw', sa.String(length=20), nullable=False),
sa.Column('serial', sa.String(length=20), nullable=False),
sa.Column('point_per_km', sa.String(length=20), nullable=False),
sa.Column('qra', sa.String(length=20), nullable=False),
sa.Column('other_exch', sa.String(length=255), nullable=False),
sa.Column('scoring', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_contest_template_name'), 'contest_template', ['name'], unique=False)
op.create_table('contests',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('start', sa.DateTime(), nullable=False),
sa.Column('end', sa.DateTime(), nullable=False),
sa.Column('template', sa.Integer(), nullable=False),
sa.Column('serial_num', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('dxcc',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('prefix', sa.String(length=10), nullable=False),
sa.Column('name', sa.String(length=150), nullable=True),
sa.Column('cqz', sa.Float(), nullable=False),
sa.Column('ituz', sa.Float(), nullable=False),
sa.Column('cont', sa.String(length=5), nullable=False),
sa.Column('long', sa.Float(), nullable=False),
sa.Column('lat', sa.Float(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_dxcc_prefix'), 'dxcc', ['prefix'], unique=False)
op.create_table('dxccexceptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('prefix', sa.String(length=10), nullable=False),
sa.Column('name', sa.String(length=150), nullable=True),
sa.Column('cqz', sa.Float(), nullable=False),
sa.Column('ituz', sa.Float(), nullable=False),
sa.Column('cont', sa.String(length=5), nullable=False),
sa.Column('long', sa.Float(), nullable=False),
sa.Column('lat', sa.Float(), nullable=False),
sa.Column('start', sa.DateTime(), nullable=False),
sa.Column('end', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_dxccexceptions_prefix'), 'dxccexceptions', ['prefix'], unique=False)
op.create_table('modes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('mode', sa.String(length=255), nullable=False),
sa.Column('submode', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('role',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('password', sa.String(length=255), nullable=False),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('callsign', sa.String(length=32), nullable=True),
sa.Column('locator', sa.String(length=16), nullable=True),
sa.Column('firstname', sa.String(length=32), nullable=True),
sa.Column('lastname', sa.String(length=32), nullable=True),
sa.Column('lotw_name', sa.String(length=32), nullable=True),
sa.Column('lotw_password', sa.String(length=255), nullable=True),
sa.Column('eqsl_name', sa.String(length=32), nullable=True),
sa.Column('eqsl_password', sa.String(length=255), nullable=True),
sa.Column('timezone', sa.String(length=255), nullable=False),
sa.Column('swl', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('name')
)
op.create_table('apitoken',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('secret', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('secret'),
sa.UniqueConstraint('token')
)
op.create_table('log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('address', sa.String(length=255), nullable=True),
sa.Column('age', sa.Integer(), nullable=True),
sa.Column('a_index', sa.Float(), nullable=True),
sa.Column('ant_az', sa.Float(), nullable=True),
sa.Column('ant_el', sa.Float(), nullable=True),
sa.Column('ant_path', sa.String(length=2), nullable=True),
sa.Column('arrl_sect', sa.String(length=10), nullable=True),
sa.Column('band_id', sa.Integer(), nullable=False),
sa.Column('band_rx', sa.String(length=10), nullable=True),
sa.Column('biography', sa.Text(), nullable=True),
sa.Column('call', sa.String(length=32), nullable=True),
sa.Column('check', sa.String(length=8), nullable=True),
sa.Column('klass', sa.String(length=8), nullable=True),
sa.Column('cnty', sa.String(length=32), nullable=True),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('cont', sa.String(length=6), nullable=True),
sa.Column('contacted_op', sa.String(length=32), nullable=True),
sa.Column('contest_id', sa.String(length=32), nullable=True),
sa.Column('country', sa.String(length=64), nullable=True),
sa.Column('cqz', sa.Integer(), nullable=True),
sa.Column('distance', sa.Float(), nullable=True),
sa.Column('dxcc', sa.String(length=6), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('eq_call', sa.String(length=32), nullable=True),
sa.Column('eqsl_qslrdate', sa.DateTime(), nullable=True),
sa.Column('eqsl_qslsdate', sa.DateTime(), nullable=True),
sa.Column('eqsl_qsl_rcvd', sa.String(length=2), nullable=True),
sa.Column('eqsl_qsl_sent', sa.String(length=2), nullable=True),
sa.Column('esql_status', sa.String(length=255), nullable=True),
sa.Column('force_init', sa.Integer(), nullable=True),
sa.Column('freq', sa.Integer(), nullable=True),
sa.Column('freq_rx', sa.Integer(), nullable=True),
sa.Column('gridsquare', sa.String(length=12), nullable=True),
sa.Column('heading', sa.Float(), nullable=True),
sa.Column('iota', sa.String(length=10), nullable=True),
sa.Column('ituz', sa.Integer(), nullable=True),
sa.Column('k_index', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('lon', sa.Float(), nullable=True),
sa.Column('lotw_qslrdate', sa.DateTime(), nullable=True),
sa.Column('lotw_qslsdate', sa.DateTime(), nullable=True),
sa.Column('lotw_qsl_rcvd', sa.String(length=2), nullable=True),
sa.Column('lotw_qsl_sent', sa.String(length=2), nullable=True),
sa.Column('lotw_status', sa.String(length=255), nullable=True),
sa.Column('max_bursts', sa.Integer(), nullable=True),
sa.Column('mode_id', sa.Integer(), nullable=False),
sa.Column('ms_shower', sa.String(length=32), nullable=True),
sa.Column('my_city', sa.String(length=32), nullable=True),
sa.Column('my_cnty', sa.String(length=32), nullable=True),
sa.Column('my_country', sa.String(length=64), nullable=True),
sa.Column('my_cq_zone', sa.Integer(), nullable=True),
sa.Column('my_gridsquare', sa.String(length=12), nullable=True),
sa.Column('my_iota', sa.String(length=10), nullable=True),
sa.Column('my_itu_zone', sa.String(length=11), nullable=True),
sa.Column('my_lat', sa.Float(), nullable=True),
sa.Column('my_lon', sa.Float(), nullable=True),
sa.Column('my_name', sa.String(length=255), nullable=True),
sa.Column('my_postal_code', sa.String(length=24), nullable=True),
sa.Column('my_rig', sa.String(length=255), nullable=True),
sa.Column('my_sig', sa.String(length=32), nullable=True),
sa.Column('my_sig_info', sa.String(length=64), nullable=True),
sa.Column('my_state', sa.String(length=32), nullable=True),
sa.Column('my_street', sa.String(length=64), nullable=True),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('nr_bursts', sa.Integer(), nullable=True),
sa.Column('nr_pings', sa.Integer(), nullable=True),
sa.Column('operator', sa.String(length=32), nullable=True),
sa.Column('owner_callsign', sa.String(length=32), nullable=True),
sa.Column('pfx', sa.String(length=32), nullable=True),
sa.Column('precedence', sa.String(length=32), nullable=True),
sa.Column('prop_mode', sa.String(length=8), nullable=True),
sa.Column('public_key', sa.String(length=255), nullable=True),
sa.Column('qslmsg', sa.String(length=255), nullable=True),
sa.Column('qslrdate', sa.DateTime(), nullable=True),
sa.Column('qslsdate', sa.DateTime(), nullable=True),
sa.Column('qsl_rcvd', sa.String(length=2), nullable=True),
sa.Column('qsl_rcvd_via', sa.String(length=2), nullable=True),
sa.Column('qsl_sent', sa.String(length=2), nullable=True),
sa.Column('qsl_sent_via', sa.String(length=2), nullable=True),
sa.Column('qsl_via', sa.String(length=64), nullable=True),
sa.Column('qso_complete', sa.String(length=6), nullable=True),
sa.Column('qso_random', sa.String(length=11), nullable=True),
sa.Column('qth', sa.String(length=64), nullable=True),
sa.Column('rig', sa.String(length=255), nullable=True),
sa.Column('rst_rcvd', sa.String(length=32), nullable=True),
sa.Column('rst_sent', sa.String(length=32), nullable=True),
sa.Column('rx_pwr', sa.Float(), nullable=True),
sa.Column('sat_mode', sa.String(length=32), nullable=True),
sa.Column('sat_name', sa.String(length=32), nullable=True),
sa.Column('sfi', sa.Float(), nullable=True),
sa.Column('sig', sa.String(length=32), nullable=True),
sa.Column('sig_info', sa.String(length=64), nullable=True),
sa.Column('srx', sa.String(length=11), nullable=True),
sa.Column('srx_string', sa.String(length=32), nullable=True),
sa.Column('state', sa.String(length=32), nullable=True),
sa.Column('station_callsign', sa.String(length=32), nullable=True),
sa.Column('stx', sa.String(length=11), nullable=True),
sa.Column('stx_info', sa.String(length=32), nullable=True),
sa.Column('swl', sa.Integer(), nullable=True),
sa.Column('ten_ten', sa.Integer(), nullable=True),
sa.Column('time_off', sa.DateTime(), nullable=True),
sa.Column('time_on', sa.DateTime(), nullable=True),
sa.Column('tx_pwr', sa.Float(), nullable=True),
sa.Column('web', sa.String(length=255), nullable=True),
sa.Column('user_defined_0', sa.String(length=64), nullable=True),
sa.Column('user_defined_1', sa.String(length=64), nullable=True),
sa.Column('user_defined_2', sa.String(length=64), nullable=True),
sa.Column('user_defined_3', sa.String(length=64), nullable=True),
sa.Column('user_defined_4', sa.String(length=64), nullable=True),
sa.Column('user_defined_5', sa.String(length=64), nullable=True),
sa.Column('user_defined_6', sa.String(length=64), nullable=True),
sa.Column('user_defined_7', sa.String(length=64), nullable=True),
sa.Column('user_defined_8', sa.String(length=64), nullable=True),
sa.Column('user_defined_9', sa.String(length=64), nullable=True),
sa.Column('credit_granted', sa.String(length=64), nullable=True),
sa.Column('credit_submitted', sa.String(length=64), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['band_id'], ['bands.id'], ),
sa.ForeignKeyConstraint(['mode_id'], ['modes.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_log_call'), 'log', ['call'], unique=False)
op.create_index(op.f('ix_log_cont'), 'log', ['cont'], unique=False)
op.create_index(op.f('ix_log_dxcc'), 'log', ['dxcc'], unique=False)
op.create_index(op.f('ix_log_iota'), 'log', ['iota'], unique=False)
op.create_index(op.f('ix_log_pfx'), 'log', ['pfx'], unique=False)
op.create_index(op.f('ix_log_sat_mode'), 'log', ['sat_mode'], unique=False)
op.create_index(op.f('ix_log_sat_name'), 'log', ['sat_name'], unique=False)
op.create_index(op.f('ix_log_time_on'), 'log', ['time_on'], unique=False)
op.create_table('notes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('cat', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('note', sa.Text(), nullable=False),
sa.Column('timestamp', sa.DateTime(), server_default=sa.text(u'CURRENT_TIMESTAMP'), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('roles_users',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
)
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('roles_users')
op.drop_table('notes')
op.drop_index(op.f('ix_log_time_on'), table_name='log')
op.drop_index(op.f('ix_log_sat_name'), table_name='log')
op.drop_index(op.f('ix_log_sat_mode'), table_name='log')
op.drop_index(op.f('ix_log_pfx'), table_name='log')
op.drop_index(op.f('ix_log_iota'), table_name='log')
op.drop_index(op.f('ix_log_dxcc'), table_name='log')
op.drop_index(op.f('ix_log_cont'), table_name='log')
op.drop_index(op.f('ix_log_call'), table_name='log')
op.drop_table('log')
op.drop_table('apitoken')
op.drop_table('user')
op.drop_table('role')
op.drop_table('modes')
op.drop_index(op.f('ix_dxccexceptions_prefix'), table_name='dxccexceptions')
op.drop_table('dxccexceptions')
op.drop_index(op.f('ix_dxcc_prefix'), table_name='dxcc')
op.drop_table('dxcc')
op.drop_table('contests')
op.drop_index(op.f('ix_contest_template_name'), table_name='contest_template')
op.drop_table('contest_template')
op.drop_table('config')
op.drop_table('cat')
op.drop_table('bands')
### end Alembic commands ###

+ 394
- 0
models.py View File

@@ -0,0 +1,394 @@
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.security import SQLAlchemyUserDatastore, UserMixin, RoleMixin

from sqlalchemy.sql import func
from sqlalchemy_searchable import make_searchable

from libqth import is_valid_qth, qth_to_coords, coords_to_qth
from geohelper import distance

import datetime

db = SQLAlchemy()
make_searchable()


roles_users = db.Table('roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('role.id')))


class Role(db.Model, RoleMixin):
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True, nullable=False, info={'label': 'Name'})
description = db.Column(db.String(255), info={'label': 'Description'})

__mapper_args__ = {"order_by": name}


class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, info={'label': 'Email'})
name = db.Column(db.String(255), unique=True, nullable=False, info={'label': 'Name'})
password = db.Column(db.String(255), nullable=False, info={'label': 'Password'})
active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime())

callsign = db.Column(db.String(32))
locator = db.Column(db.String(16))
firstname = db.Column(db.String(32))
lastname = db.Column(db.String(32))
lotw_name = db.Column(db.String(32))
lotw_password = db.Column(db.String(255))
eqsl_name = db.Column(db.String(32))
eqsl_password = db.Column(db.String(255))
timezone = db.Column(db.String(255), nullable=False) # Managed and fed by pytz
swl = db.Column(db.Boolean(), nullable=False, default=False)

roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic'))
logs = db.relationship('Log', backref='user', lazy='dynamic')
notes = db.relationship('Note', backref='user', lazy='dynamic')
apitokens = db.relationship('Apitoken', backref='user', lazy='dynamic')

__mapper_args__ = {"order_by": name}

def join_roles(self, string):
return string.join([i.description for i in self.roles])

def qth_to_coords(self):
qth = is_valid_qth(self.locator, 6)
if not qth:
return None
qth = qth_to_coords(self.locator, 6)
if not qth:
return None
return qth

# Give a cute name <3 More like "name - callsign" or "callsign"
def cutename(self):
cute = ""
if self.name:
cute += self.name
cute += " - "
cute += self.callsign
return cute


class Apitoken(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer(), db.ForeignKey('user.id'), nullable=False)
token = db.Column(db.String(), unique=True, nullable=False, info={'label': 'Token'})
secret = db.Column(db.String(), unique=True, nullable=False, info={'label': 'Secret'})


user_datastore = SQLAlchemyUserDatastore(db, User, Role)


class Cat(db.Model):
__tablename__ = "cat"

id = db.Column(db.Integer, primary_key=True)
radio = db.Column(db.String(250), nullable=False)
frequency = db.Column(db.Integer(), nullable=False)
mode = db.Column(db.String(10), nullable=False)
timestamp = db.Column(db.DateTime(timezone=False), nullable=False, server_default=func.now(), onupdate=func.now())


class Config(db.Model):
__tablename__ = "config"

id = db.Column(db.Integer, primary_key=True)
lotw_download_url = db.Column(db.String(255), default=None)
lotw_upload_url = db.Column(db.String(255), default=None)
lotw_rcvd_mark = db.Column(db.String(255), default=None)
lotw_login_url = db.Column(db.String(255), default=None)
eqsl_download_url = db.Column(db.String(255), default=None)
eqsl_rcvd_mark = db.Column(db.String(255), default=None)


class ContestTemplate(db.Model):
__tablename__ = "contest_template"

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, index=True)
band_160 = db.Column(db.String(20), nullable=False)
band_80 = db.Column(db.String(20), nullable=False)
band_40 = db.Column(db.String(20), nullable=False)
band_20 = db.Column(db.String(20), nullable=False)
band_15 = db.Column(db.String(20), nullable=False)
band_10 = db.Column(db.String(20), nullable=False)
band_6m = db.Column(db.String(20), nullable=False)
band_4m = db.Column(db.String(20), nullable=False)
band_2m = db.Column(db.String(20), nullable=False)
band_70cm = db.Column(db.String(20), nullable=False)
band_23cm = db.Column(db.String(20), nullable=False)
mode_ssb = db.Column(db.String(20), nullable=False)
mode_cw = db.Column(db.String(20), nullable=False)
serial = db.Column(db.String(20), nullable=False)
point_per_km = db.Column(db.String(20), nullable=False)
qra = db.Column(db.String(20), nullable=False)
other_exch = db.Column(db.String(255), nullable=False)
scoring = db.Column(db.String(255), nullable=False)


class Contest(db.Model):
__tablename__ = "contests"

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
start = db.Column(db.DateTime(timezone=False), nullable=False)
end = db.Column(db.DateTime(timezone=False), nullable=False)
template = db.Column(db.Integer(), nullable=False)
serial_num = db.Column(db.Integer(), nullable=False)
# possible FK template->contest_template


class Dxcc(db.Model):
__tablename__ = "dxcc"

id = db.Column(db.Integer, primary_key=True)
prefix = db.Column(db.String(10), nullable=False, index=True)
name = db.Column(db.String(150), default=None)
cqz = db.Column(db.Float, nullable=False)
ituz = db.Column(db.Float, nullable=False)
cont = db.Column(db.String(5), nullable=False)
long = db.Column(db.Float, nullable=False)
lat = db.Column(db.Float, nullable=False)


class DxccException(db.Model):
__tablename__ = "dxccexceptions"

id = db.Column(db.Integer, primary_key=True)
prefix = db.Column(db.String(10), nullable=False, index=True)
name = db.Column(db.String(150), default=None)
cqz = db.Column(db.Float, nullable=False)
ituz = db.Column(db.Float, nullable=False)
cont = db.Column(db.String(5), nullable=False)
long = db.Column(db.Float, nullable=False)
lat = db.Column(db.Float, nullable=False)

# 0000-00-00 00:00:00 date is invalid for DateTime
# So instead we EPOCH them
start = db.Column(db.DateTime(timezone=False), nullable=False)
end = db.Column(db.DateTime(timezone=False), nullable=False)


class Log(db.Model):
__tablename__ = "log"

id = db.Column(db.Integer, primary_key=True)
address = db.Column(db.String(255), default=None)
age = db.Column(db.Integer, default=None)
a_index = db.Column(db.Float, default=None)
ant_az = db.Column(db.Float, default=None)
ant_el = db.Column(db.Float, default=None)
ant_path = db.Column(db.String(2), default=None)
arrl_sect = db.Column(db.String(10), default=None)

band_id = db.Column(db.Integer(), db.ForeignKey('bands.id'), nullable=False)

band_rx = db.Column(db.String(10), default=None)
biography = db.Column(db.Text)
call = db.Column(db.String(32), default=None, index=True)
check = db.Column(db.String(8), default=None)
klass = db.Column(db.String(8), default=None)
cnty = db.Column(db.String(32), default=None)
comment = db.Column(db.Text)
cont = db.Column(db.String(6), default=None, index=True)
contacted_op = db.Column(db.String(32), default=None)
contest_id = db.Column(db.String(32), default=None)
country = db.Column(db.String(64), default=None)
cqz = db.Column(db.Integer, default=None)
distance = db.Column(db.Float, default=None)
dxcc = db.Column(db.String(6), default=None, index=True)
email = db.Column(db.String(255), default=None)
eq_call = db.Column(db.String(32), default=None)
eqsl_qslrdate = db.Column(db.DateTime(timezone=False), default=None)
eqsl_qslsdate = db.Column(db.DateTime(timezone=False), default=None)
eqsl_qsl_rcvd = db.Column(db.String(2), default=None)
eqsl_qsl_sent = db.Column(db.String(2), default=None)
esql_status = db.Column(db.String(255), default=None)
force_init = db.Column(db.Integer, default=None)
freq = db.Column(db.Integer, default=None)
freq_rx = db.Column(db.Integer, default=None)
gridsquare = db.Column(db.String(12), default=None)
heading = db.Column(db.Float, default=None)
iota = db.Column(db.String(10), default=None, index=True)
ituz = db.Column(db.Integer, default=None)
k_index = db.Column(db.Float, default=None)
lat = db.Column(db.Float, default=None)
lon = db.Column(db.Float, default=None)
lotw_qslrdate = db.Column(db.DateTime(timezone=False), default=None)
lotw_qslsdate = db.Column(db.DateTime(timezone=False), default=None)
lotw_qsl_rcvd = db.Column(db.String(2), default=None)
lotw_qsl_sent = db.Column(db.String(2), default=None)
lotw_status = db.Column(db.String(255), default=None)
max_bursts = db.Column(db.Integer, default=None)

mode_id = db.Column(db.Integer(), db.ForeignKey('modes.id'), nullable=False)

ms_shower = db.Column(db.String(32), default=None)
my_city = db.Column(db.String(32), default=None)
my_cnty = db.Column(db.String(32), default=None)
my_country = db.Column(db.String(64), default=None)
my_cq_zone = db.Column(db.Integer, default=None)
my_gridsquare = db.Column(db.String(12), default=None)
my_iota = db.Column(db.String(10), default=None)
my_itu_zone = db.Column(db.String(11), default=None)
my_lat = db.Column(db.Float, default=None)
my_lon = db.Column(db.Float, default=None)
my_name = db.Column(db.String(255), default=None)
my_postal_code = db.Column(db.String(24), default=None)
my_rig = db.Column(db.String(255), default=None)
my_sig = db.Column(db.String(32), default=None)
my_sig_info = db.Column(db.String(64), default=None)
my_state = db.Column(db.String(32), default=None)
my_street = db.Column(db.String(64), default=None)
name = db.Column(db.String(128), default=None)
notes = db.Column(db.Text, default=None)
nr_bursts = db.Column(db.Integer, default=None)
nr_pings = db.Column(db.Integer, default=None)
operator = db.Column(db.String(32), default=None)
owner_callsign = db.Column(db.String(32), default=None)
pfx = db.Column(db.String(32), default=None, index=True)
precedence = db.Column(db.String(32), default=None)
prop_mode = db.Column(db.String(8), default=None)
public_key = db.Column(db.String(255), default=None)
qslmsg = db.Column(db.String(255), default=None)
qslrdate = db.Column(db.DateTime(timezone=False), default=None)
qslsdate = db.Column(db.DateTime(timezone=False), default=None)
qsl_rcvd = db.Column(db.String(2), default=None)
qsl_rcvd_via = db.Column(db.String(2), default=None)
qsl_sent = db.Column(db.String(2), default=None)
qsl_sent_via = db.Column(db.String(2), default=None)
qsl_via = db.Column(db.String(64), default=None)
qso_complete = db.Column(db.String(6), default=None)
qso_random = db.Column(db.String(11), default=None)
qth = db.Column(db.String(64), default=None)
rig = db.Column(db.String(255), default=None)
rst_rcvd = db.Column(db.String(32), default=None)
rst_sent = db.Column(db.String(32), default=None)
rx_pwr = db.Column(db.Float, default=None)
sat_mode = db.Column(db.String(32), default=None, index=True)
sat_name = db.Column(db.String(32), default=None, index=True)
sfi = db.Column(db.Float, default=None)
sig = db.Column(db.String(32), default=None)
sig_info = db.Column(db.String(64), default=None)
srx = db.Column(db.String(11), default=None)
srx_string = db.Column(db.String(32), default=None)
state = db.Column(db.String(32), default=None)
station_callsign = db.Column(db.String(32), default=None)
stx = db.Column(db.String(11), default=None)
stx_info = db.Column(db.String(32), default=None)
swl = db.Column(db.Integer, default=None)
ten_ten = db.Column(db.Integer, default=None)
time_off = db.Column(db.DateTime(timezone=False), default=None)
time_on = db.Column(db.DateTime(timezone=False), default=None, index=True)
tx_pwr = db.Column(db.Float, default=None)
web = db.Column(db.String(255), default=None)
user_defined_0 = db.Column(db.String(64), default=None)
user_defined_1 = db.Column(db.String(64), default=None)
user_defined_2 = db.Column(db.String(64), default=None)
user_defined_3 = db.Column(db.String(64), default=None)
user_defined_4 = db.Column(db.String(64), default=None)
user_defined_5 = db.Column(db.String(64), default=None)
user_defined_6 = db.Column(db.String(64), default=None)
user_defined_7 = db.Column(db.String(64), default=None)
user_defined_8 = db.Column(db.String(64), default=None)
user_defined_9 = db.Column(db.String(64), default=None)
credit_granted = db.Column(db.String(64), default=None)
credit_submitted = db.Column(db.String(64), default=None)

user_id = db.Column(db.Integer(), db.ForeignKey('user.id'), nullable=False)

__mapper_args__ = {"order_by": time_on.desc()}

# Give a cute name <3 More like "name - callsign" or "callsign"
def cutename(self):
cute = ""
if self.name:
cute += self.name
cute += " - "
cute += self.call
return cute

def country_grid_coords(self):
if 'sqlite' in db.engine.driver:
q = Dxcc.query.filter(Dxcc.prefix == func.substr(self.call, 1, func.LENGTH(Dxcc.prefix))).order_by(
func.length(Dxcc.prefix).desc()).limit(1)