Merge branch 'location'

Add Location model which holds textual, geolocation coordiantes
	or postal addresses. This migrates data off Image model metadata
	onto the general Location model. It also adds the ability for location
	to be set on MediaEntry, User, MediaComment and Collection models.

	The geolocation plugin has been updated so that the location can be displayed
	in more general places rather than explicitely on the MediaEntry view.
	If GPS coordiantes are set for the User the profile page will also have the
	OSM provided by the geolocation plugin.
This commit is contained in:
Jessica Tallon 2014-10-09 19:20:13 +01:00
commit ed48454558
14 changed files with 266 additions and 25 deletions

View File

@ -1086,3 +1086,40 @@ def activity_migration(db):
).where(collection_table.c.id==collection.id)) ).where(collection_table.c.id==collection.id))
db.commit() db.commit()
class Location_V0(declarative_base()):
__tablename__ = "core__locations"
id = Column(Integer, primary_key=True)
name = Column(Unicode)
position = Column(MutationDict.as_mutable(JSONEncoded))
address = Column(MutationDict.as_mutable(JSONEncoded))
@RegisterMigration(25, MIGRATIONS)
def add_location_model(db):
""" Add location model """
metadata = MetaData(bind=db.bind)
# Create location table
Location_V0.__table__.create(db.bind)
db.commit()
# Inspect the tables we need
user = inspect_table(metadata, "core__users")
collections = inspect_table(metadata, "core__collections")
media_entry = inspect_table(metadata, "core__media_entries")
media_comments = inspect_table(metadata, "core__media_comments")
# Now add location support to the various models
col = Column("location", Integer, ForeignKey(Location_V0.id))
col.create(user)
col = Column("location", Integer, ForeignKey(Location_V0.id))
col.create(collections)
col = Column("location", Integer, ForeignKey(Location_V0.id))
col.create(media_entry)
col = Column("location", Integer, ForeignKey(Location_V0.id))
col.create(media_comments)
db.commit()

View File

@ -45,6 +45,79 @@ import six
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
class Location(Base):
""" Represents a physical location """
__tablename__ = "core__locations"
id = Column(Integer, primary_key=True)
name = Column(Unicode)
# GPS coordinates
position = Column(MutationDict.as_mutable(JSONEncoded))
address = Column(MutationDict.as_mutable(JSONEncoded))
@classmethod
def create(cls, data, obj):
location = cls()
location.unserialize(data)
location.save()
obj.location = location.id
return location
def serialize(self, request):
location = {"objectType": "place"}
if self.name is not None:
location["name"] = self.name
if self.position:
location["position"] = self.position
if self.address:
location["address"] = self.address
return location
def unserialize(self, data):
if "name" in data:
self.name = data["name"]
self.position = {}
self.address = {}
# nicer way to do this?
if "position" in data:
# TODO: deal with ISO 9709 formatted string as position
if "altitude" in data["position"]:
self.position["altitude"] = data["position"]["altitude"]
if "direction" in data["position"]:
self.position["direction"] = data["position"]["direction"]
if "longitude" in data["position"]:
self.position["longitude"] = data["position"]["longitude"]
if "latitude" in data["position"]:
self.position["latitude"] = data["position"]["latitude"]
if "address" in data:
if "formatted" in data["address"]:
self.address["formatted"] = data["address"]["formatted"]
if "streetAddress" in data["address"]:
self.address["streetAddress"] = data["address"]["streetAddress"]
if "locality" in data["address"]:
self.address["locality"] = data["address"]["locality"]
if "region" in data["address"]:
self.address["region"] = data["address"]["region"]
if "postalCode" in data["address"]:
self.address["postalCode"] = data["addresss"]["postalCode"]
if "country" in data["address"]:
self.address["country"] = data["address"]["country"]
class User(Base, UserMixin): class User(Base, UserMixin):
""" """
@ -71,6 +144,8 @@ class User(Base, UserMixin):
bio = Column(UnicodeText) # ?? bio = Column(UnicodeText) # ??
uploaded = Column(Integer, default=0) uploaded = Column(Integer, default=0)
upload_limit = Column(Integer) upload_limit = Column(Integer)
location = Column(Integer, ForeignKey("core__locations.id"))
get_location = relationship("Location", lazy="joined")
activity = Column(Integer, ForeignKey("core__activity_intermediators.id")) activity = Column(Integer, ForeignKey("core__activity_intermediators.id"))
@ -175,9 +250,18 @@ class User(Base, UserMixin):
user.update({"summary": self.bio}) user.update({"summary": self.bio})
if self.url: if self.url:
user.update({"url": self.url}) user.update({"url": self.url})
if self.location:
user.update({"location": self.get_location.seralize(request)})
return user return user
def unserialize(self, data):
if "summary" in data:
self.bio = data["summary"]
if "location" in data:
Location.create(data, self)
class Client(Base): class Client(Base):
""" """
Model representing a client - Used for API Auth Model representing a client - Used for API Auth
@ -265,6 +349,8 @@ class MediaEntry(Base, MediaEntryMixin):
# or use sqlalchemy.types.Enum? # or use sqlalchemy.types.Enum?
license = Column(Unicode) license = Column(Unicode)
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
location = Column(Integer, ForeignKey("core__locations.id"))
get_location = relationship("Location", lazy="joined")
fail_error = Column(Unicode) fail_error = Column(Unicode)
fail_metadata = Column(JSONEncoded) fail_metadata = Column(JSONEncoded)
@ -476,6 +562,9 @@ class MediaEntry(Base, MediaEntryMixin):
if self.license: if self.license:
context["license"] = self.license context["license"] = self.license
if self.location:
context["location"] = self.get_location.serialize(request)
if show_comments: if show_comments:
comments = [ comments = [
comment.serialize(request) for comment in self.get_comments()] comment.serialize(request) for comment in self.get_comments()]
@ -504,6 +593,9 @@ class MediaEntry(Base, MediaEntryMixin):
if "license" in data: if "license" in data:
self.license = data["license"] self.license = data["license"]
if "location" in data:
Licence.create(data["location"], self)
return True return True
class FileKeynames(Base): class FileKeynames(Base):
@ -629,6 +721,8 @@ class MediaComment(Base, MediaCommentMixin):
author = Column(Integer, ForeignKey(User.id), nullable=False) author = Column(Integer, ForeignKey(User.id), nullable=False)
created = Column(DateTime, nullable=False, default=datetime.datetime.now) created = Column(DateTime, nullable=False, default=datetime.datetime.now)
content = Column(UnicodeText, nullable=False) content = Column(UnicodeText, nullable=False)
location = Column(Integer, ForeignKey("core__locations.id"))
get_location = relationship("Location", lazy="joined")
# Cascade: Comments are owned by their creator. So do the full thing. # Cascade: Comments are owned by their creator. So do the full thing.
# lazy=dynamic: People might post a *lot* of comments, # lazy=dynamic: People might post a *lot* of comments,
@ -666,6 +760,9 @@ class MediaComment(Base, MediaCommentMixin):
"author": author.serialize(request) "author": author.serialize(request)
} }
if self.location:
context["location"] = self.get_location.seralize(request)
return context return context
def unserialize(self, data): def unserialize(self, data):
@ -692,6 +789,10 @@ class MediaComment(Base, MediaCommentMixin):
self.media_entry = media.id self.media_entry = media.id
self.content = data["content"] self.content = data["content"]
if "location" in data:
Location.create(data["location"], self)
return True return True
@ -710,6 +811,9 @@ class Collection(Base, CollectionMixin):
index=True) index=True)
description = Column(UnicodeText) description = Column(UnicodeText)
creator = Column(Integer, ForeignKey(User.id), nullable=False) creator = Column(Integer, ForeignKey(User.id), nullable=False)
location = Column(Integer, ForeignKey("core__locations.id"))
get_location = relationship("Location", lazy="joined")
# TODO: No of items in Collection. Badly named, can we migrate to num_items? # TODO: No of items in Collection. Badly named, can we migrate to num_items?
items = Column(Integer, default=0) items = Column(Integer, default=0)
@ -889,9 +993,8 @@ class ProcessingNotification(Notification):
'polymorphic_identity': 'processing_notification' 'polymorphic_identity': 'processing_notification'
} }
with_polymorphic( # the with_polymorphic call has been moved to the bottom above MODELS
Notification, # this is because it causes conflicts with relationship calls.
[ProcessingNotification, CommentNotification])
class ReportBase(Base): class ReportBase(Base):
""" """
@ -1243,6 +1346,10 @@ class Activity(Base, ActivityMixin):
self.updated = datetime.datetime.now() self.updated = datetime.datetime.now()
super(Activity, self).save(*args, **kwargs) super(Activity, self).save(*args, **kwargs)
with_polymorphic(
Notification,
[ProcessingNotification, CommentNotification])
MODELS = [ MODELS = [
User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
@ -1250,7 +1357,8 @@ MODELS = [
CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan, CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan,
Privilege, PrivilegeUserAssociation, Privilege, PrivilegeUserAssociation,
RequestToken, AccessToken, NonceTimestamp, RequestToken, AccessToken, NonceTimestamp,
Activity, ActivityIntermediator, Generator] Activity, ActivityIntermediator, Generator,
Location]
""" """
Foundations are the default rows that are created immediately after the tables Foundations are the default rows that are created immediately after the tables

View File

@ -61,6 +61,7 @@ class EditProfileForm(wtforms.Form):
[wtforms.validators.Optional(), [wtforms.validators.Optional(),
wtforms.validators.URL(message=_("This address contains errors"))]) wtforms.validators.URL(message=_("This address contains errors"))])
location = wtforms.TextField(_('Hometown'))
class EditAccountForm(wtforms.Form): class EditAccountForm(wtforms.Form):
wants_comment_notification = wtforms.BooleanField( wants_comment_notification = wtforms.BooleanField(

View File

@ -47,7 +47,7 @@ from mediagoblin.tools.text import (
convert_to_tag_list_of_dicts, media_tags_as_string) convert_to_tag_list_of_dicts, media_tags_as_string)
from mediagoblin.tools.url import slugify from mediagoblin.tools.url import slugify
from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
from mediagoblin.db.models import User, Client, AccessToken from mediagoblin.db.models import User, Client, AccessToken, Location
import mimetypes import mimetypes
@ -202,14 +202,29 @@ def edit_profile(request, url_user=None):
user = url_user user = url_user
# Get the location name
if user.location is None:
location = ""
else:
location = user.get_location.name
form = forms.EditProfileForm(request.form, form = forms.EditProfileForm(request.form,
url=user.url, url=user.url,
bio=user.bio) bio=user.bio,
location=location)
if request.method == 'POST' and form.validate(): if request.method == 'POST' and form.validate():
user.url = six.text_type(form.url.data) user.url = six.text_type(form.url.data)
user.bio = six.text_type(form.bio.data) user.bio = six.text_type(form.bio.data)
# Save location
if form.location.data and user.location is None:
user.get_location = Location(name=unicode(form.location.data))
elif form.location.data:
location = user.get_location.name
location.name = unicode(form.location.data)
location.save()
user.save() user.save()
messages.add_message(request, messages.add_message(request,

View File

@ -208,6 +208,11 @@ def feed_endpoint(request):
"Invalid 'image' with id '{0}'".format(media_id) "Invalid 'image' with id '{0}'".format(media_id)
) )
# Add location if one exists
if "location" in data:
Location.create(data["location"], self)
media.save() media.save()
api_add_to_feed(request, media) api_add_to_feed(request, media)
@ -303,6 +308,15 @@ def feed_endpoint(request):
"object": image.serialize(request), "object": image.serialize(request),
} }
return json_response(activity) return json_response(activity)
elif obj["objectType"] == "person":
# check this is the same user
if "id" not in obj or obj["id"] != requested_user.id:
return json_error(
"Incorrect user id, unable to update"
)
requested_user.unserialize(obj)
requested_user.save()
elif request.method != "GET": elif request.method != "GET":
return json_error( return json_error(

View File

@ -13,5 +13,61 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
from sqlalchemy import MetaData, Column, ForeignKey
from mediagoblin.db.migration_tools import RegisterMigration, inspect_table
MIGRATIONS = {} MIGRATIONS = {}
@RegisterMigration(1, MIGRATIONS)
def remove_gps_from_image(db):
"""
This will remove GPS coordinates from the image model to put them
on the new Location model.
"""
metadata = MetaData(bind=db.bind)
image_table = inspect_table(metadata, "image__mediadata")
location_table = inspect_table(metadata, "core__locations")
media_entires_table = inspect_table(metadata, "core__media_entries")
# First do the data migration
for row in db.execute(image_table.select()):
fields = {
"longitude": row.gps_longitude,
"latitude": row.gps_latitude,
"altitude": row.gps_altitude,
"direction": row.gps_direction,
}
# Remove empty values
for k, v in fields.items():
if v is None:
del fields[k]
# No point in adding empty locations
if not fields:
continue
# JSONEncoded is actually a string field just json.dumped
# without the ORM we're responsible for that.
fields = json.dumps(fields)
location = db.execute(location_table.insert().values(position=fields))
# now store the new location model on Image
db.execute(media_entires_table.update().values(
location=location.inserted_primary_key[0]
).where(media_entires_table.c.id==row.media_entry))
db.commit()
# All that data has been migrated across lets remove the fields
image_table.columns["gps_longitude"].drop()
image_table.columns["gps_latitude"].drop()
image_table.columns["gps_altitude"].drop()
image_table.columns["gps_direction"].drop()
db.commit()

View File

@ -39,10 +39,6 @@ class ImageData(Base):
width = Column(Integer) width = Column(Integer)
height = Column(Integer) height = Column(Integer)
exif_all = Column(JSONEncoded) exif_all = Column(JSONEncoded)
gps_longitude = Column(Float)
gps_latitude = Column(Float)
gps_altitude = Column(Float)
gps_direction = Column(Float)
DATA_MODEL = ImageData DATA_MODEL = ImageData

View File

@ -27,6 +27,7 @@ import argparse
import six import six
from mediagoblin import mg_globals as mgg from mediagoblin import mg_globals as mgg
from mediagoblin.db.models import Location
from mediagoblin.processing import ( from mediagoblin.processing import (
BadMediaFail, FilenameBuilder, BadMediaFail, FilenameBuilder,
MediaProcessor, ProcessingManager, MediaProcessor, ProcessingManager,
@ -235,8 +236,7 @@ class CommonImageProcessor(MediaProcessor):
self.entry.media_data_init(exif_all=exif_all) self.entry.media_data_init(exif_all=exif_all)
if len(gps_data): if len(gps_data):
for key in list(gps_data.keys()): Location.create({"position": gps_data}, self.entry)
gps_data['gps_' + key] = gps_data.pop(key)
self.entry.media_data_init(**gps_data) self.entry.media_data_init(**gps_data)

View File

@ -26,8 +26,8 @@ def setup_plugin():
pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates'))
pluginapi.register_template_hooks( pluginapi.register_template_hooks(
{"image_sideinfo": "mediagoblin/plugins/geolocation/map.html", {"location_info": "mediagoblin/plugins/geolocation/map.html",
"image_head": "mediagoblin/plugins/geolocation/map_js_head.html"}) "location_head": "mediagoblin/plugins/geolocation/map_js_head.html"})
hooks = { hooks = {

View File

@ -17,14 +17,13 @@
#} #}
{% block geolocation_map %} {% block geolocation_map %}
{% if media.media_data.gps_latitude is defined {% if model.location
and media.media_data.gps_latitude and model.get_location.position
and media.media_data.gps_longitude is defined and model.get_location.position.latitude
and media.media_data.gps_longitude %} and model.get_location.position.longitude %}
<h3>{% trans %}Location{% endtrans %}</h3>
<div> <div>
{%- set lon = media.media_data.gps_longitude %} {%- set lon = model.get_location.position.longitude %}
{%- set lat = media.media_data.gps_latitude %} {%- set lat = model.get_location.position.latitude %}
{%- set osm_url = "http://openstreetmap.org/?mlat={lat}&mlon={lon}".format(lat=lat, lon=lon) %} {%- set osm_url = "http://openstreetmap.org/?mlat={lat}&mlon={lon}".format(lat=lat, lon=lon) %}
<div id="tile-map" style="width: 100%; height: 196px;"> <div id="tile-map" style="width: 100%; height: 196px;">
<input type="hidden" id="gps-longitude" <input type="hidden" id="gps-longitude"

View File

@ -30,6 +30,7 @@
<script type="text/javascript" <script type="text/javascript"
src="{{ request.staticdirect('/js/keyboard_navigation.js') }}"></script> src="{{ request.staticdirect('/js/keyboard_navigation.js') }}"></script>
{% template_hook("location_head") %}
{% template_hook("media_head") %} {% template_hook("media_head") %}
{% endblock mediagoblin_head %} {% endblock mediagoblin_head %}
{% block mediagoblin_content %} {% block mediagoblin_content %}
@ -231,6 +232,8 @@
{% block mediagoblin_sidebar %} {% block mediagoblin_sidebar %}
{% endblock %} {% endblock %}
{%- set model = media %}
{% template_hook("location_info") %}
{% template_hook("media_sideinfo") %} {% template_hook("media_sideinfo") %}
</div><!--end media_sidebar--> </div><!--end media_sidebar-->

View File

@ -46,7 +46,7 @@
{%- trans username=user.username %}{{ username }}'s profile{% endtrans -%} {%- trans username=user.username %}{{ username }}'s profile{% endtrans -%}
</h1> </h1>
{% if not user.url and not user.bio %} {% if not user.url and not user.bio and not user.location %}
{% if request.user and (request.user.id == user.id) %} {% if request.user and (request.user.id == user.id) %}
<div class="profile_sidebar empty_space"> <div class="profile_sidebar empty_space">
<p> <p>

View File

@ -16,6 +16,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
#} #}
{% block mediagoblin_head %}
{% template_hook("location_head") %}
{% endblock mediagoblin_head %}
{% block profile_content -%} {% block profile_content -%}
{% if user.bio %} {% if user.bio %}
{% autoescape False %} {% autoescape False %}
@ -27,4 +31,12 @@
<a href="{{ user.url }}">{{ user.url }}</a> <a href="{{ user.url }}">{{ user.url }}</a>
</p> </p>
{% endif %} {% endif %}
{% if user.location %}
{%- set model = user %}
<h3>{% trans %}Location{% endtrans %}</h3>
{% if model.get_location.name %}
<p>{{ model.get_location.name }}</p>
{% endif %}
{% template_hook("location_info") %}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -363,7 +363,7 @@ class TestSubmission:
def test_media_data(self): def test_media_data(self):
self.check_normal_upload(u"With GPS data", GPS_JPG) self.check_normal_upload(u"With GPS data", GPS_JPG)
media = self.check_media(None, {"title": u"With GPS data"}, 1) media = self.check_media(None, {"title": u"With GPS data"}, 1)
assert media.media_data.gps_latitude == 59.336666666666666 assert media.get_location.position["latitude"] == 59.336666666666666
def test_processing(self): def test_processing(self):
public_store_dir = mg_globals.global_config[ public_store_dir = mg_globals.global_config[