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))
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__)
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):
"""
@ -71,6 +144,8 @@ class User(Base, UserMixin):
bio = Column(UnicodeText) # ??
uploaded = Column(Integer, default=0)
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"))
@ -175,9 +250,18 @@ class User(Base, UserMixin):
user.update({"summary": self.bio})
if self.url:
user.update({"url": self.url})
if self.location:
user.update({"location": self.get_location.seralize(request)})
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):
"""
Model representing a client - Used for API Auth
@ -265,6 +349,8 @@ class MediaEntry(Base, MediaEntryMixin):
# or use sqlalchemy.types.Enum?
license = Column(Unicode)
file_size = Column(Integer, default=0)
location = Column(Integer, ForeignKey("core__locations.id"))
get_location = relationship("Location", lazy="joined")
fail_error = Column(Unicode)
fail_metadata = Column(JSONEncoded)
@ -476,6 +562,9 @@ class MediaEntry(Base, MediaEntryMixin):
if self.license:
context["license"] = self.license
if self.location:
context["location"] = self.get_location.serialize(request)
if show_comments:
comments = [
comment.serialize(request) for comment in self.get_comments()]
@ -504,6 +593,9 @@ class MediaEntry(Base, MediaEntryMixin):
if "license" in data:
self.license = data["license"]
if "location" in data:
Licence.create(data["location"], self)
return True
class FileKeynames(Base):
@ -629,6 +721,8 @@ class MediaComment(Base, MediaCommentMixin):
author = Column(Integer, ForeignKey(User.id), nullable=False)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
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.
# lazy=dynamic: People might post a *lot* of comments,
@ -666,6 +760,9 @@ class MediaComment(Base, MediaCommentMixin):
"author": author.serialize(request)
}
if self.location:
context["location"] = self.get_location.seralize(request)
return context
def unserialize(self, data):
@ -692,6 +789,10 @@ class MediaComment(Base, MediaCommentMixin):
self.media_entry = media.id
self.content = data["content"]
if "location" in data:
Location.create(data["location"], self)
return True
@ -710,6 +811,9 @@ class Collection(Base, CollectionMixin):
index=True)
description = Column(UnicodeText)
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?
items = Column(Integer, default=0)
@ -889,9 +993,8 @@ class ProcessingNotification(Notification):
'polymorphic_identity': 'processing_notification'
}
with_polymorphic(
Notification,
[ProcessingNotification, CommentNotification])
# the with_polymorphic call has been moved to the bottom above MODELS
# this is because it causes conflicts with relationship calls.
class ReportBase(Base):
"""
@ -1243,6 +1346,10 @@ class Activity(Base, ActivityMixin):
self.updated = datetime.datetime.now()
super(Activity, self).save(*args, **kwargs)
with_polymorphic(
Notification,
[ProcessingNotification, CommentNotification])
MODELS = [
User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
@ -1250,7 +1357,8 @@ MODELS = [
CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan,
Privilege, PrivilegeUserAssociation,
RequestToken, AccessToken, NonceTimestamp,
Activity, ActivityIntermediator, Generator]
Activity, ActivityIntermediator, Generator,
Location]
"""
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.URL(message=_("This address contains errors"))])
location = wtforms.TextField(_('Hometown'))
class EditAccountForm(wtforms.Form):
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)
from mediagoblin.tools.url import slugify
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
@ -202,14 +202,29 @@ def edit_profile(request, url_user=None):
user = url_user
# Get the location name
if user.location is None:
location = ""
else:
location = user.get_location.name
form = forms.EditProfileForm(request.form,
url=user.url,
bio=user.bio)
bio=user.bio,
location=location)
if request.method == 'POST' and form.validate():
user.url = six.text_type(form.url.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()
messages.add_message(request,

View File

@ -208,6 +208,11 @@ def feed_endpoint(request):
"Invalid 'image' with id '{0}'".format(media_id)
)
# Add location if one exists
if "location" in data:
Location.create(data["location"], self)
media.save()
api_add_to_feed(request, media)
@ -303,6 +308,15 @@ def feed_endpoint(request):
"object": image.serialize(request),
}
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":
return json_error(

View File

@ -13,5 +13,61 @@
#
# 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/>.
import json
from sqlalchemy import MetaData, Column, ForeignKey
from mediagoblin.db.migration_tools import RegisterMigration, inspect_table
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)
height = Column(Integer)
exif_all = Column(JSONEncoded)
gps_longitude = Column(Float)
gps_latitude = Column(Float)
gps_altitude = Column(Float)
gps_direction = Column(Float)
DATA_MODEL = ImageData

View File

@ -27,6 +27,7 @@ import argparse
import six
from mediagoblin import mg_globals as mgg
from mediagoblin.db.models import Location
from mediagoblin.processing import (
BadMediaFail, FilenameBuilder,
MediaProcessor, ProcessingManager,
@ -235,8 +236,7 @@ class CommonImageProcessor(MediaProcessor):
self.entry.media_data_init(exif_all=exif_all)
if len(gps_data):
for key in list(gps_data.keys()):
gps_data['gps_' + key] = gps_data.pop(key)
Location.create({"position": gps_data}, self.entry)
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_hooks(
{"image_sideinfo": "mediagoblin/plugins/geolocation/map.html",
"image_head": "mediagoblin/plugins/geolocation/map_js_head.html"})
{"location_info": "mediagoblin/plugins/geolocation/map.html",
"location_head": "mediagoblin/plugins/geolocation/map_js_head.html"})
hooks = {

View File

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

View File

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

View File

@ -46,7 +46,7 @@
{%- trans username=user.username %}{{ username }}'s profile{% endtrans -%}
</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) %}
<div class="profile_sidebar empty_space">
<p>

View File

@ -16,6 +16,10 @@
# 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 -%}
{% if user.bio %}
{% autoescape False %}
@ -27,4 +31,12 @@
<a href="{{ user.url }}">{{ user.url }}</a>
</p>
{% 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 %}

View File

@ -363,7 +363,7 @@ class TestSubmission:
def test_media_data(self):
self.check_normal_upload(u"With GPS data", GPS_JPG)
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):
public_store_dir = mg_globals.global_config[