Switch to a Vue-based web app

* Init Webpack / Babel / etc setup.
* Build the app using Vue, Vue-router, Vuex.
* i18n

Some backends changes were made to match the webapp development:
* Return the flat status as a single string ("new" rather than
"FlatStatus.new")

* Completely switch to calling Weboob API directly for fetching
* Use Canister for Bottle logging
* Handle merging of details dict better
* Add a WSGI script
* Keep track of duplicates
* Webserver had to be restarted to fetch external changes to the db
* Handle leboncoin module better

Also add contributions guidelines.

Closes issue #3
Closes issue #14.
This commit is contained in:
Lucas Verney 2017-04-13 23:24:31 +02:00
parent 4966fe2111
commit a57d9ce8e3
49 changed files with 1987 additions and 119 deletions

4
.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["es2015", "stage-0"],
"plugins": ["transform-runtime"]
}

10
.eslintrc Normal file
View File

@ -0,0 +1,10 @@
{
extends: ["vue", /* your other extends */],
plugins: ["vue"],
"env": {
"browser": true
},
rules: {
'indent': ["error", 4, { 'SwitchCase': 1 }],
}
}

4
.gitignore vendored
View File

@ -1,6 +1,8 @@
build build
*.json
*.pyc *.pyc
*.swp *.swp
*.swo *.swo
*.db *.db
config/
node_modules
flatisfy/web/static/js

46
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,46 @@
## TL;DR
* Branch off `master`.
* One feature per commit.
* In case of changes request, amend your commit.
## Useful infos
* There is a `hooks/pre-commit` file which can be used as a `pre-commit` git
hook to check coding style.
* Python coding style is PEP8. JS coding style is enforced by `eslint`.
* Some useful `npm` scripts are provided (`build` / `watch` / `lint`)
## Translating the webapp
If you want to translate the webapp, just create a new folder in
`flatisfy/web/js_src/i18n` with the short name of your locale (typically, `en`
is for english). Copy the `flatisfy/web/js_src/i18n/en/index.js` file to this
new folder and translate the `messages` strings.
Then, edit `flatisfy/web/js_src/i18n/index.js` file to include your new
locale.
## How to contribute
* If you're thinking about a new feature, see if there's already an issue open
about it, or please open one otherwise. This will ensure that everybody is on
track for the feature and willing to see it in Flatisfy.
* One commit per feature.
* Branch off the `master ` branch.
* Check the linting of your code before doing a PR.
* Ideally, your merge-request should be mergeable without any merge commit, that
is, it should be a fast-forward merge. For this to happen, your code needs to
be always rebased onto `master`. Again, this is something nice to have that
I expect from recurring contributors, but not a big deal if you don't do it
otherwise.
* I'll look at it and might ask for a few changes. In this case, please create
new commits. When the final result looks good, I may ask you to squash the
WIP commits into a single one, to maintain the invariant of "one feature, one
commit".
Thanks!

View File

@ -104,6 +104,11 @@ The content of this repository is licensed under an MIT license, unless
explicitly mentionned otherwise. explicitly mentionned otherwise.
## Contributing
See the `CONTRIBUTING.md` file for more infos.
## Thanks ## Thanks
* [Weboob](http://weboob.org/) * [Weboob](http://weboob.org/)

View File

@ -90,6 +90,10 @@ def parse_args(argv=None):
subparsers.add_parser("import", parents=[parent_parser], subparsers.add_parser("import", parents=[parent_parser],
help="Import housing posts in database.") help="Import housing posts in database.")
# Purge subcommand parser
subparsers.add_parser("purge", parents=[parent_parser],
help="Purge database.")
# Serve subcommand parser # Serve subcommand parser
parser_serve = subparsers.add_parser("serve", parents=[parent_parser], parser_serve = subparsers.add_parser("serve", parents=[parent_parser],
help="Serve the web app.") help="Serve the web app.")
@ -103,6 +107,7 @@ def main():
""" """
Main module code. Main module code.
""" """
# pylint: disable=locally-disabled,too-many-branches
# Parse arguments # Parse arguments
args = parse_args() args = parse_args()
@ -163,7 +168,12 @@ def main():
) )
# Import command # Import command
elif args.cmd == "import": elif args.cmd == "import":
# TODO: Do not fetch details for already imported flats / use the last
# timestamp
cmds.import_and_filter(config) cmds.import_and_filter(config)
# Purge command
elif args.cmd == "purge":
cmds.purge_db(config)
# Serve command # Serve command
elif args.cmd == "serve": elif args.cmd == "serve":
cmds.serve(config) cmds.serve(config)

View File

@ -4,6 +4,8 @@ Main commands available for flatisfy.
""" """
from __future__ import absolute_import, print_function, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import logging
import flatisfy.filters import flatisfy.filters
from flatisfy import database from flatisfy import database
from flatisfy.models import flat as flat_model from flatisfy.models import flat as flat_model
@ -12,6 +14,9 @@ from flatisfy import tools
from flatisfy.web import app as web_app from flatisfy.web import app as web_app
LOGGER = logging.getLogger(__name__)
def fetch_and_filter(config): def fetch_and_filter(config):
""" """
Fetch the available flats list. Then, filter it according to criteria. Fetch the available flats list. Then, filter it according to criteria.
@ -34,9 +39,9 @@ def fetch_and_filter(config):
# additional infos # additional infos
if config["passes"] > 1: if config["passes"] > 1:
# Load additional infos # Load additional infos
for flat in flats_list: for i, flat in enumerate(flats_list):
details = fetch.fetch_details(flat["id"]) details = fetch.fetch_details(config, flat["id"])
flat = tools.merge_dicts(flat, details) flats_list[i] = tools.merge_dicts(flat, details)
flats_list, extra_ignored_flats = flatisfy.filters.second_pass( flats_list, extra_ignored_flats = flatisfy.filters.second_pass(
flats_list, config flats_list, config
@ -83,7 +88,7 @@ def import_and_filter(config):
:return: ``None``. :return: ``None``.
""" """
# Fetch and filter flats list # Fetch and filter flats list
flats_list, purged_list = fetch_and_filter(config) flats_list, ignored_list = fetch_and_filter(config)
# Create database connection # Create database connection
get_session = database.init_db(config["database"]) get_session = database.init_db(config["database"])
@ -92,12 +97,27 @@ def import_and_filter(config):
flat = flat_model.Flat.from_dict(flat_dict) flat = flat_model.Flat.from_dict(flat_dict)
session.merge(flat) session.merge(flat)
for flat_dict in purged_list: for flat_dict in ignored_list:
flat = flat_model.Flat.from_dict(flat_dict) flat = flat_model.Flat.from_dict(flat_dict)
flat.status = flat_model.FlatStatus.purged flat.status = flat_model.FlatStatus.ignored
session.merge(flat) session.merge(flat)
def purge_db(config):
"""
Purge the database.
:param config: A config dict.
:return: ``None``
"""
get_session = database.init_db(config["database"])
with get_session() as session:
# Delete every flat in the db
LOGGER.info("Purge all flats from the database.")
session.query(flat_model.Flat).delete(synchronize_session=False)
def serve(config): def serve(config):
""" """
Serve the web app. Serve the web app.
@ -106,5 +126,11 @@ def serve(config):
:return: ``None``, long-running process. :return: ``None``, long-running process.
""" """
app = web_app.get_app(config) app = web_app.get_app(config)
# TODO: Make Bottle use logging module
app.run(host=config["host"], port=config["port"]) server = config.get("webserver", None)
if not server:
# Default webserver is quiet, as Bottle is used with Canister for
# standard logging
server = web_app.QuietWSGIRefServer
app.run(host=config["host"], port=config["port"], server=server)

View File

@ -21,10 +21,11 @@ from flatisfy import tools
# Default configuration # Default configuration
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
# Flatboob queries to fetch
"queries": [],
# Constraints to match # Constraints to match
"constraints": { "constraints": {
"type": None, # RENT, SALE, SHARING
"house_types": [], # List of house types, must be in APART, HOUSE,
# PARKING, LAND, OTHER or UNKNOWN
"postal_codes": [], # List of postal codes "postal_codes": [], # List of postal codes
"area": (None, None), # (min, max) in m^2 "area": (None, None), # (min, max) in m^2
"cost": (None, None), # (min, max) in currency unit "cost": (None, None), # (min, max) in currency unit
@ -42,12 +43,18 @@ DEFAULT_CONFIG = {
"max_entries": None, "max_entries": None,
# Directory in wich data will be put. ``None`` is XDG default location. # Directory in wich data will be put. ``None`` is XDG default location.
"data_directory": None, "data_directory": None,
# Path to the modules directory containing all Weboob modules. ``None`` if
# ``weboob_modules`` package is pip-installed, and you want to use
# ``pkgresource`` to automatically find it.
"modules_path": None,
# SQLAlchemy URI to the database to use # SQLAlchemy URI to the database to use
"database": None, "database": None,
# Web app port # Web app port
"port": 8080, "port": 8080,
# Web app host to listen on # Web app host to listen on
"host": "127.0.0.1" "host": "127.0.0.1",
# Web server to use to serve the webapp (see Bottle deployment doc)
"webserver": None
} }
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -68,7 +75,7 @@ def validate_config(config):
assert all( assert all(
x is None or x is None or
( (
(isinstance(x, int) or isinstance(x, float)) and isinstance(x, (float, int)) and
x >= 0 x >= 0
) )
for x in bounds for x in bounds
@ -81,9 +88,19 @@ def validate_config(config):
# Then, we disable line-too-long pylint check and E501 flake8 checks # Then, we disable line-too-long pylint check and E501 flake8 checks
# and use long lines whenever needed, in order to have the full assert # and use long lines whenever needed, in order to have the full assert
# message in the log output. # message in the log output.
# pylint: disable=line-too-long # pylint: disable=locally-disabled,line-too-long
assert "type" in config["constraints"]
assert config["constraints"]["type"].upper() in ["RENT",
"SALE", "SHARING"]
assert "house_types" in config["constraints"]
assert config["constraints"]["house_types"]
for house_type in config["constraints"]["house_types"]:
assert house_type.upper() in ["APART", "HOUSE", "PARKING", "LAND",
"OTHER", "UNKNOWN"]
assert "postal_codes" in config["constraints"] assert "postal_codes" in config["constraints"]
assert len(config["constraints"]["postal_codes"]) > 0 assert config["constraints"]["postal_codes"]
assert "area" in config["constraints"] assert "area" in config["constraints"]
_check_constraints_bounds(config["constraints"]["area"]) _check_constraints_bounds(config["constraints"]["area"])
@ -111,11 +128,13 @@ def validate_config(config):
assert config["max_entries"] is None or (isinstance(config["max_entries"], int) and config["max_entries"] > 0) # noqa: E501 assert config["max_entries"] is None or (isinstance(config["max_entries"], int) and config["max_entries"] > 0) # noqa: E501
assert config["data_directory"] is None or isinstance(config["data_directory"], str) # noqa: E501 assert config["data_directory"] is None or isinstance(config["data_directory"], str) # noqa: E501
assert config["modules_path"] is None or isinstance(config["modules_path"], str) # noqa: E501
assert config["database"] is None or isinstance(config["database"], str) # noqa: E501 assert config["database"] is None or isinstance(config["database"], str) # noqa: E501
assert isinstance(config["port"], int) assert isinstance(config["port"], int)
assert isinstance(config["host"], str) assert isinstance(config["host"], str)
assert config["webserver"] is None or isinstance(config["webserver"], str) # noqa: E501
return True return True
except (AssertionError, KeyError): except (AssertionError, KeyError):
@ -140,10 +159,11 @@ def load_config(args=None):
try: try:
with open(args.config, "r") as fh: with open(args.config, "r") as fh:
config_data.update(json.load(fh)) config_data.update(json.load(fh))
except (IOError, ValueError): except (IOError, ValueError) as exc:
LOGGER.error( LOGGER.error(
"Unable to load configuration from file, " "Unable to load configuration from file, "
"using default configuration." "using default configuration: %s.",
exc
) )
# Overload config with arguments # Overload config with arguments
@ -188,9 +208,8 @@ def load_config(args=None):
if config_validation is True: if config_validation is True:
LOGGER.info("Config has been fully initialized.") LOGGER.info("Config has been fully initialized.")
return config_data return config_data
else: LOGGER.error("Error in configuration: %s.", config_validation)
LOGGER.error("Error in configuration: %s.", config_validation) return None
return None
def init_config(output=None): def init_config(output=None):

View File

@ -9,6 +9,7 @@ import collections
import json import json
import logging import logging
import os import os
import shutil
import flatisfy.exceptions import flatisfy.exceptions
@ -157,7 +158,7 @@ def load_data(data_type, config):
LOGGER.error("Invalid JSON data file: %s.", datafile_path) LOGGER.error("Invalid JSON data file: %s.", datafile_path)
return None return None
if len(data) == 0: if not data:
LOGGER.warning("Loading empty data for %s.", data_type) LOGGER.warning("Loading empty data for %s.", data_type)
return data return data

View File

@ -41,16 +41,18 @@ def init_db(database_uri=None):
engine = create_engine(database_uri) engine = create_engine(database_uri)
BASE.metadata.create_all(engine, checkfirst=True) BASE.metadata.create_all(engine, checkfirst=True)
Session = sessionmaker(bind=engine) # pylint: disable=invalid-name Session = sessionmaker(bind=engine) # pylint: disable=locally-disabled,invalid-name
@contextmanager @contextmanager
def get_session(): def get_session():
# pylint: disable=locally-disabled,line-too-long
""" """
Provide a transactional scope around a series of operations. Provide a transactional scope around a series of operations.
From [1]. From [1].
[1]: http://docs.sqlalchemy.org/en/latest/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it. [1]: http://docs.sqlalchemy.org/en/latest/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it.
""" """
# pylint: enable=line-too-long,locally-disabled
session = Session() session = Session()
try: try:
yield session yield session

View File

@ -46,5 +46,5 @@ class StringyJSON(types.TypeDecorator):
# TypeEngine.with_variant says "use StringyJSON instead when # TypeEngine.with_variant says "use StringyJSON instead when
# connecting to 'sqlite'" # connecting to 'sqlite'"
# pylint: disable=invalid-name # pylint: disable=locally-disabled,invalid-name
MagicJSON = types.JSON().with_variant(StringyJSON, 'sqlite') MagicJSON = types.JSON().with_variant(StringyJSON, 'sqlite')

View File

@ -4,14 +4,159 @@ This module contains all the code related to fetching and loading flats lists.
""" """
from __future__ import absolute_import, print_function, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import itertools
import json import json
import logging import logging
import subprocess
from flatisfy import data
from flatisfy import tools
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
try:
from weboob.capabilities.housing import Query
from weboob.core.ouiboube import WebNip
from weboob.tools.json import WeboobEncoder
except ImportError:
LOGGER.error("Weboob is not available on your system. Make sure you "
"installed it.")
raise
class WeboobProxy(object):
"""
Wrapper around Weboob ``WebNip`` class, to fetch housing posts without
having to spawn a subprocess.
"""
@staticmethod
def version():
"""
Get Weboob version.
:return: The installed Weboob version.
"""
return WebNip.VERSION
def __init__(self, config):
"""
Create a Weboob handle and try to load the modules.
:param config: A config dict.
"""
# Create base WebNip object
self.webnip = WebNip(modules_path=config["modules_path"])
# Create backends
self.backends = [
self.webnip.load_backend(
module,
module,
params={}
)
for module in ["seloger", "pap", "leboncoin", "logicimmo",
"explorimmo", "entreparticuliers"]
]
def __enter__(self):
return self
def __exit__(self, *args):
self.webnip.deinit()
def build_queries(self, constraints_dict):
"""
Build Weboob ``weboob.capabilities.housing.Query`` objects from the
constraints defined in the configuration. Each query has at most 3
postal codes, to comply with housing websites limitations.
:param constraints_dict: A dictionary of constraints, as defined in the
config.
:return: A list of Weboob ``weboob.capabilities.housing.Query``
objects. Returns ``None`` if an error occurred.
"""
queries = []
for postal_codes in tools.batch(constraints_dict["postal_codes"], 3):
query = Query()
query.cities = []
for postal_code in postal_codes:
try:
for city in self.webnip.do("search_city", postal_code):
query.cities.append(city)
except IndexError:
LOGGER.error(
"Postal code %s could not be matched with a city.",
postal_code
)
return None
try:
query.house_types = [
getattr(
Query.HOUSE_TYPES,
house_type.upper()
)
for house_type in constraints_dict["house_types"]
]
except AttributeError:
LOGGER.error("Invalid house types constraint.")
return None
try:
query.type = getattr(
Query,
"TYPE_{}".format(constraints_dict["type"].upper())
)
except AttributeError:
LOGGER.error("Invalid post type constraint.")
return None
query.area_min = constraints_dict["area"][0]
query.area_max = constraints_dict["area"][1]
query.cost_min = constraints_dict["cost"][0]
query.cost_max = constraints_dict["cost"][1]
query.nb_rooms = constraints_dict["rooms"][0]
queries.append(query)
return queries
def query(self, query, max_entries=None):
"""
Fetch the housings posts matching a given Weboob query.
:param query: A Weboob `weboob.capabilities.housing.Query`` object.
:param max_entries: Maximum number of entries to fetch.
:return: The matching housing posts, dumped as a list of JSON objects.
"""
housings = []
# TODO: Handle max_entries better
for housing in itertools.islice(
self.webnip.do('search_housings', query),
max_entries
):
housings.append(json.dumps(housing, cls=WeboobEncoder))
return housings
def info(self, full_flat_id):
"""
Get information (details) about an housing post.
:param full_flat_id: A Weboob housing post id, in complete form
(ID@BACKEND)
:return: The details in JSON.
"""
flat_id, backend_name = full_flat_id.rsplit("@", 1)
backend = next(
backend
for backend in self.backends
if backend.name == backend_name
)
housing = backend.get_housing(flat_id)
housing.id = full_flat_id # Otherwise, we miss the @backend afterwards
return json.dumps(housing, cls=WeboobEncoder)
def fetch_flats_list(config): def fetch_flats_list(config):
""" """
Fetch the available flats using the Flatboob / Weboob config. Fetch the available flats using the Flatboob / Weboob config.
@ -20,40 +165,35 @@ def fetch_flats_list(config):
:return: A list of all available flats. :return: A list of all available flats.
""" """
flats_list = [] flats_list = []
for query in config["queries"]:
max_entries = config["max_entries"]
if max_entries is None:
max_entries = 0
LOGGER.info("Loading flats from query %s.", query) with WeboobProxy(config) as weboob_proxy:
flatboob_output = subprocess.check_output( LOGGER.info("Loading flats...")
["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob", queries = weboob_proxy.build_queries(config["constraints"])
"-n", str(max_entries), "-f", "json", "load", query] housing_posts = []
) for query in queries:
query_flats_list = json.loads(flatboob_output) housing_posts.extend(
LOGGER.info("Fetched %d flats.", len(query_flats_list)) weboob_proxy.query(query, config["max_entries"])
flats_list.extend(query_flats_list) )
LOGGER.info("Fetched a total of %d flats.", len(flats_list)) LOGGER.info("Fetched %d flats.", len(housing_posts))
flats_list = [json.loads(flat) for flat in housing_posts]
return flats_list return flats_list
def fetch_details(flat_id): def fetch_details(config, flat_id):
""" """
Fetch the additional details for a flat using Flatboob / Weboob. Fetch the additional details for a flat using Flatboob / Weboob.
:param config: A config dict.
:param flat_id: ID of the flat to fetch details for. :param flat_id: ID of the flat to fetch details for.
:return: A flat dict with all the available data. :return: A flat dict with all the available data.
""" """
LOGGER.info("Loading additional details for flat %s.", flat_id) with WeboobProxy(config) as weboob_proxy:
flatboob_output = subprocess.check_output( LOGGER.info("Loading additional details for flat %s.", flat_id)
["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob", weboob_output = weboob_proxy.info(flat_id)
"-f", "json", "info", flat_id]
)
flat_details = json.loads(flatboob_output)
LOGGER.info("Fetched details for flat %s.", flat_id)
if flat_details: flat_details = json.loads(weboob_output)
flat_details = flat_details[0] LOGGER.info("Fetched details for flat %s.", flat_id)
return flat_details return flat_details

View File

@ -89,9 +89,10 @@ def first_pass(flats_list, config):
:param flats_list: A list of flats dict to filter. :param flats_list: A list of flats dict to filter.
:param config: A config dict. :param config: A config dict.
:return: A tuple of processed flats and purged flats. :return: A tuple of processed flats and ignored flats.
""" """
LOGGER.info("Running first filtering pass.") LOGGER.info("Running first filtering pass.")
# Handle duplicates based on ids # Handle duplicates based on ids
# Just remove them (no merge) as they should be the exact same object. # Just remove them (no merge) as they should be the exact same object.
flats_list = duplicates.detect( flats_list = duplicates.detect(
@ -105,16 +106,16 @@ def first_pass(flats_list, config):
flats_list, key="url", merge=True flats_list, key="url", merge=True
) )
# Add the flatisfy metadata entry # Add the flatisfy metadata entry and prepare the flat objects
flats_list = metadata.init(flats_list) flats_list = metadata.init(flats_list)
# Guess the postal codes # Guess the postal codes
flats_list = metadata.guess_postal_code(flats_list, config) flats_list = metadata.guess_postal_code(flats_list, config)
# Try to match with stations # Try to match with stations
flats_list = metadata.guess_stations(flats_list, config) flats_list = metadata.guess_stations(flats_list, config)
# Remove returned housing posts that do not match criteria # Remove returned housing posts that do not match criteria
flats_list, purged_list = refine_with_housing_criteria(flats_list, config) flats_list, ignored_list = refine_with_housing_criteria(flats_list, config)
return (flats_list, purged_list) return (flats_list, ignored_list)
def second_pass(flats_list, config): def second_pass(flats_list, config):
@ -130,7 +131,7 @@ def second_pass(flats_list, config):
:param flats_list: A list of flats dict to filter. :param flats_list: A list of flats dict to filter.
:param config: A config dict. :param config: A config dict.
:return: A tuple of processed flats and purged flats. :return: A tuple of processed flats and ignored flats.
""" """
LOGGER.info("Running second filtering pass.") LOGGER.info("Running second filtering pass.")
# Assumed to run after first pass, so there should be no obvious duplicates # Assumed to run after first pass, so there should be no obvious duplicates
@ -148,6 +149,6 @@ def second_pass(flats_list, config):
flats_list = metadata.compute_travel_times(flats_list, config) flats_list = metadata.compute_travel_times(flats_list, config)
# Remove returned housing posts that do not match criteria # Remove returned housing posts that do not match criteria
flats_list, purged_list = refine_with_housing_criteria(flats_list, config) flats_list, ignored_list = refine_with_housing_criteria(flats_list, config)
return (flats_list, purged_list) return (flats_list, ignored_list)

View File

@ -5,9 +5,23 @@ Filtering functions to detect and merge duplicates.
from __future__ import absolute_import, print_function, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import collections import collections
import logging
from flatisfy import tools from flatisfy import tools
LOGGER = logging.getLogger(__name__)
# Some backends give more infos than others. Here is the precedence we want to
# use.
BACKENDS_PRECEDENCE = [
"seloger",
"pap",
"leboncoin",
"explorimmo",
"logicimmo",
"entreparticuliers"
]
def detect(flats_list, key="id", merge=True): def detect(flats_list, key="id", merge=True):
""" """
@ -27,7 +41,6 @@ def detect(flats_list, key="id", merge=True):
:return: A deduplicated list of flat dicts. :return: A deduplicated list of flat dicts.
""" """
# TODO: Keep track of found duplicates?
# ``seen`` is a dict mapping aggregating the flats by the deduplication # ``seen`` is a dict mapping aggregating the flats by the deduplication
# keys. We basically make buckets of flats for every key value. Flats in # keys. We basically make buckets of flats for every key value. Flats in
# the same bucket should be merged together afterwards. # the same bucket should be merged together afterwards.
@ -44,6 +57,18 @@ def detect(flats_list, key="id", merge=True):
# of the others, to avoid over-deduplication. # of the others, to avoid over-deduplication.
unique_flats_list.extend(matching_flats) unique_flats_list.extend(matching_flats)
else: else:
# Sort matching flats by backend precedence
matching_flats.sort(
key=lambda flat: next(
i for (i, backend) in enumerate(BACKENDS_PRECEDENCE)
if flat["id"].endswith(backend)
),
reverse=True
)
if len(matching_flats) > 1:
LOGGER.info("Found duplicates: %s.",
[flat["id"] for flat in matching_flats])
# Otherwise, check the policy # Otherwise, check the policy
if merge: if merge:
# If a merge is requested, do the merge # If a merge is requested, do the merge

View File

@ -20,14 +20,20 @@ LOGGER = logging.getLogger(__name__)
def init(flats_list): def init(flats_list):
""" """
Create a flatisfy key containing a dict of metadata fetched by flatisfy for Create a flatisfy key containing a dict of metadata fetched by flatisfy for
each flat in the list. each flat in the list. Also perform some basic transform on flat objects to
prepare for the metadata fetching.
:param flats_list: A list of flats dict. :param flats_list: A list of flats dict.
:return: The updated list :return: The updated list
""" """
for flat in flats_list: for flat in flats_list:
# Init flatisfy key
if "flatisfy" not in flat: if "flatisfy" not in flat:
flat["flatisfy"] = {} flat["flatisfy"] = {}
# Move url key to urls
flat["urls"] = [flat["url"]]
# Create merged_ids key
flat["merged_ids"] = [flat["id"]]
return flats_list return flats_list
@ -298,11 +304,17 @@ def guess_stations(flats_list, config, distance_threshold=1500):
# If some stations were already filled in and the result is different, # If some stations were already filled in and the result is different,
# display some warning to the user # display some warning to the user
if ( if (
"matched_stations" in flat["flatisfy"]["matched_stations"] and "matched_stations" in flat["flatisfy"] and
( (
# Do a set comparison, as ordering is not important # Do a set comparison, as ordering is not important
set(flat["flatisfy"]["matched_stations"]) != set([
set(good_matched_stations) station["name"]
for station in flat["flatisfy"]["matched_stations"]
]) !=
set([
station["name"]
for station in good_matched_stations
])
) )
): ):
LOGGER.warning( LOGGER.warning(

View File

@ -2,9 +2,12 @@
""" """
This modules defines an SQLAlchemy ORM model for a flat. This modules defines an SQLAlchemy ORM model for a flat.
""" """
# pylint: disable=invalid-name,too-few-public-methods # pylint: disable=locally-disabled,invalid-name,too-few-public-methods
from __future__ import absolute_import, print_function, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import logging
import arrow
import enum import enum
from sqlalchemy import Column, DateTime, Enum, Float, String, Text from sqlalchemy import Column, DateTime, Enum, Float, String, Text
@ -13,15 +16,29 @@ from flatisfy.database.base import BASE
from flatisfy.database.types import MagicJSON from flatisfy.database.types import MagicJSON
LOGGER = logging.getLogger(__name__)
class FlatUtilities(enum.Enum):
"""
An enum of the possible utilities status for a flat entry.
"""
included = 10
unknown = 0
excluded = -10
class FlatStatus(enum.Enum): class FlatStatus(enum.Enum):
""" """
An enum of the possible status for a flat entry. An enum of the possible status for a flat entry.
""" """
purged = -10 user_deleted = -100
ignored = -10
new = 0 new = 0
contacted = 10 followed = 10
answer_no = 20 contacted = 20
answer_yes = 21 answer_no = 30
answer_yes = 31
class Flat(BASE): class Flat(BASE):
@ -36,6 +53,7 @@ class Flat(BASE):
bedrooms = Column(Float) bedrooms = Column(Float)
cost = Column(Float) cost = Column(Float)
currency = Column(String) currency = Column(String)
utilities = Column(Enum(FlatUtilities), default=FlatUtilities.unknown)
date = Column(DateTime) date = Column(DateTime)
details = Column(MagicJSON) details = Column(MagicJSON)
location = Column(String) location = Column(String)
@ -45,7 +63,8 @@ class Flat(BASE):
station = Column(String) station = Column(String)
text = Column(Text) text = Column(Text)
title = Column(String) title = Column(String)
url = Column(String) urls = Column(MagicJSON)
merged_ids = Column(MagicJSON)
# Flatisfy data # Flatisfy data
# TODO: Should be in another table with relationships # TODO: Should be in another table with relationships
@ -65,25 +84,45 @@ class Flat(BASE):
# Handle flatisfy metadata # Handle flatisfy metadata
flat_dict = flat_dict.copy() flat_dict = flat_dict.copy()
flat_dict["flatisfy_stations"] = ( flat_dict["flatisfy_stations"] = (
flat_dict["flatisfy"].get("matched_stations", None) flat_dict["flatisfy"].get("matched_stations", [])
) )
flat_dict["flatisfy_postal_code"] = ( flat_dict["flatisfy_postal_code"] = (
flat_dict["flatisfy"].get("postal_code", None) flat_dict["flatisfy"].get("postal_code", None)
) )
flat_dict["flatisfy_time_to"] = ( flat_dict["flatisfy_time_to"] = (
flat_dict["flatisfy"].get("time_to", None) flat_dict["flatisfy"].get("time_to", {})
) )
del flat_dict["flatisfy"] del flat_dict["flatisfy"]
# Handle utilities field
if not isinstance(flat_dict["utilities"], FlatUtilities):
if flat_dict["utilities"] == "C.C.":
flat_dict["utilities"] = FlatUtilities.included
elif flat_dict["utilities"] == "H.C.":
flat_dict["utilities"] = FlatUtilities.excluded
else:
flat_dict["utilities"] = FlatUtilities.unknown
# Handle status field
flat_status = flat_dict.get("status", "new")
if not isinstance(flat_status, FlatStatus):
try:
flat_dict["status"] = getattr(FlatStatus, flat_status)
except AttributeError:
if "status" in flat_dict:
del flat_dict["status"]
LOGGER.warn("Unkown flat status %s, ignoring it.",
flat_status)
# Handle date field # Handle date field
flat_dict["date"] = None # TODO flat_dict["date"] = arrow.get(flat_dict["date"]).naive
flat_object = Flat() flat_object = Flat()
flat_object.__dict__.update(flat_dict) flat_object.__dict__.update(flat_dict)
return flat_object return flat_object
def __repr__(self): def __repr__(self):
return "<Flat(id=%s, url=%s)>" % (self.id, self.url) return "<Flat(id=%s, urls=%s)>" % (self.id, self.urls)
def json_api_repr(self): def json_api_repr(self):
@ -96,6 +135,9 @@ class Flat(BASE):
for k, v in self.__dict__.items() for k, v in self.__dict__.items()
if not k.startswith("_") if not k.startswith("_")
} }
flat_repr["status"] = str(flat_repr["status"]) if isinstance(flat_repr["status"], FlatStatus):
flat_repr["status"] = flat_repr["status"].name
if isinstance(flat_repr["utilities"], FlatUtilities):
flat_repr["utilities"] = flat_repr["utilities"].name
return flat_repr return flat_repr

View File

@ -8,6 +8,7 @@ from __future__ import (
) )
import datetime import datetime
import itertools
import json import json
import logging import logging
import math import math
@ -23,6 +24,16 @@ LOGGER = logging.getLogger(__name__)
NAVITIA_ENDPOINT = "https://api.navitia.io/v1/coverage/fr-idf/journeys" NAVITIA_ENDPOINT = "https://api.navitia.io/v1/coverage/fr-idf/journeys"
class DateAwareJSONEncoder(json.JSONEncoder):
"""
Extend the default JSON encoder to serialize datetimes to iso strings.
"""
def default(self, o): # pylint: disable=locally-disabled,E0202
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
return json.JSONEncoder.default(self, o)
def pretty_json(data): def pretty_json(data):
""" """
Pretty JSON output. Pretty JSON output.
@ -38,10 +49,25 @@ def pretty_json(data):
"toto": "ok" "toto": "ok"
} }
""" """
return json.dumps(data, indent=4, separators=(',', ': '), return json.dumps(data, cls=DateAwareJSONEncoder,
indent=4, separators=(',', ': '),
sort_keys=True) sort_keys=True)
def batch(iterable, size):
"""
Get items from a sequence a batch at a time.
:param iterable: The iterable to get the items from.
:param size: The size of the batches.
:return: A new iterable.
"""
sourceiter = iter(iterable)
while True:
batchiter = itertools.islice(sourceiter, size)
yield itertools.chain([batchiter.next()], batchiter)
def is_within_interval(value, min_value=None, max_value=None): def is_within_interval(value, min_value=None, max_value=None):
""" """
Check whether a variable is within a given interval. Assumes the value is Check whether a variable is within a given interval. Assumes the value is
@ -142,7 +168,7 @@ def distance(gps1, gps2):
lat2 = math.radians(gps2[0]) lat2 = math.radians(gps2[0])
long2 = math.radians(gps2[1]) long2 = math.radians(gps2[1])
# pylint: disable=invalid-name # pylint: disable=locally-disabled,invalid-name
a = ( a = (
math.sin((lat2 - lat1) / 2.0)**2 + math.sin((lat2 - lat1) / 2.0)**2 +
math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0)**2 math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0)**2
@ -175,22 +201,30 @@ def merge_dicts(*args):
""" """
if len(args) == 1: if len(args) == 1:
return args[0] return args[0]
else:
flat1, flat2 = args[:2] flat1, flat2 = args[:2] # pylint: disable=locally-disabled,unbalanced-tuple-unpacking,line-too-long
merged_flat = {} merged_flat = {}
for k, value2 in flat2.items(): for k, value2 in flat2.items():
value1 = flat1.get(k, None) value1 = flat1.get(k, None)
if value1 is None:
# flat1 has empty matching field, just keep the flat2 field if k in ["urls", "merged_ids"]:
merged_flat[k] = value2 # Handle special fields separately
elif value2 is None: merged_flat[k] = list(set(value2 + value1))
# flat2 field is empty, just keep the flat1 field continue
merged_flat[k] = value1
else: if not value1:
# Any other case, we should merge # flat1 has empty matching field, just keep the flat2 field
# TODO: Do the merge merged_flat[k] = value2
merged_flat[k] = value1 elif not value2:
return merge_dicts(merged_flat, *args[2:]) # flat2 field is empty, just keep the flat1 field
merged_flat[k] = value1
else:
# Any other case, we should keep the value of the more recent flat
# dict (the one most at right in arguments)
merged_flat[k] = value2
for k in [key for key in flat1.keys() if key not in flat2.keys()]:
merged_flat[k] = flat1[k]
return merge_dicts(merged_flat, *args[2:])
def get_travel_time_between(latlng_from, latlng_to, config): def get_travel_time_between(latlng_from, latlng_to, config):

View File

@ -6,15 +6,30 @@ from __future__ import (
absolute_import, division, print_function, unicode_literals absolute_import, division, print_function, unicode_literals
) )
import functools
import json
import logging
import os import os
import bottle import bottle
import canister
from flatisfy import database from flatisfy import database
from flatisfy.tools import DateAwareJSONEncoder
from flatisfy.web.routes import api as api_routes from flatisfy.web.routes import api as api_routes
from flatisfy.web.configplugin import ConfigPlugin
from flatisfy.web.dbplugin import DatabasePlugin from flatisfy.web.dbplugin import DatabasePlugin
class QuietWSGIRefServer(bottle.WSGIRefServer):
"""
Quiet implementation of Bottle built-in WSGIRefServer, as `Canister` is
handling the logging through standard Python logging.
"""
# pylint: disable=locally-disabled,too-few-public-methods
quiet = True
def _serve_static_file(filename): def _serve_static_file(filename):
""" """
Helper function to serve static file. Helper function to serve static file.
@ -38,11 +53,31 @@ def get_app(config):
app = bottle.default_app() app = bottle.default_app()
app.install(DatabasePlugin(get_session)) app.install(DatabasePlugin(get_session))
app.install(ConfigPlugin(config))
app.config.setdefault("canister.log_level", logging.root.level)
app.config.setdefault("canister.log_path", None)
app.config.setdefault("canister.debug", False)
app.install(canister.Canister())
# Use DateAwareJSONEncoder to dump JSON strings
# From http://stackoverflow.com/questions/21282040/bottle-framework-how-to-return-datetime-in-json-response#comment55718456_21282666. pylint: disable=locally-disabled,line-too-long
bottle.install(
bottle.JSONPlugin(
json_dumps=functools.partial(json.dumps, cls=DateAwareJSONEncoder)
)
)
# API v1 routes # API v1 routes
app.route("/api/v1/", "GET", api_routes.index_v1) app.route("/api/v1/", "GET", api_routes.index_v1)
app.route("/api/v1/time_to/places", "GET", api_routes.time_to_places_v1)
app.route("/api/v1/flats", "GET", api_routes.flats_v1) app.route("/api/v1/flats", "GET", api_routes.flats_v1)
app.route("/api/v1/flats/status/:status", "GET",
api_routes.flats_by_status_v1)
app.route("/api/v1/flat/:flat_id", "GET", api_routes.flat_v1) app.route("/api/v1/flat/:flat_id", "GET", api_routes.flat_v1)
app.route("/api/v1/flat/:flat_id/status", "POST",
api_routes.update_flat_status_v1)
# Index # Index
app.route("/", "GET", lambda: _serve_static_file("index.html")) app.route("/", "GET", lambda: _serve_static_file("index.html"))

View File

@ -0,0 +1,72 @@
# coding: utf-8
"""
This module contains a Bottle plugin to pass the config argument to any route
which needs it.
This module is heavily based on code from
[Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is
licensed under MIT license.
"""
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import functools
import inspect
import bottle
class ConfigPlugin(object):
"""
A Bottle plugin to automatically pass the config object to the routes
specifying they need it.
"""
name = 'config'
api = 2
KEYWORD = "config"
def __init__(self, config):
"""
:param config: The config object to pass.
"""
self.config = config
def setup(self, app): # pylint: disable=no-self-use
"""
Make sure that other installed plugins don't affect the same
keyword argument and check if metadata is available.
"""
for other in app.plugins:
if not isinstance(other, ConfigPlugin):
continue
else:
raise bottle.PluginError(
"Found another conflicting Config plugin."
)
def apply(self, callback, route):
"""
Method called on route invocation. Should apply some transformations to
the route prior to returing it.
We check the presence of ``self.KEYWORD`` in the route signature and
replace the route callback by a partial invocation where we replaced
this argument by a valid config object.
"""
# Check whether the route needs a valid db session or not.
try:
callback_args = inspect.signature(route.callback).parameters
except AttributeError:
# inspect.signature does not exist on older Python
callback_args = inspect.getargspec(route.callback).args
if self.KEYWORD not in callback_args:
# If no need for a db session, call the route callback
return callback
kwargs = {}
kwargs[self.KEYWORD] = self.config
return functools.partial(callback, **kwargs)
Plugin = ConfigPlugin

View File

@ -28,13 +28,12 @@ class DatabasePlugin(object):
def __init__(self, get_session): def __init__(self, get_session):
""" """
:param keyword: Keyword used to inject session database in a route
:param create_session: SQLAlchemy session maker created with the :param create_session: SQLAlchemy session maker created with the
'sessionmaker' function. Will create its own if undefined. 'sessionmaker' function. Will create its own if undefined.
""" """
self.get_session = get_session self.get_session = get_session
def setup(self, app): # pylint: disable-no-self-use def setup(self, app): # pylint: disable=no-self-use
""" """
Make sure that other installed plugins don't affect the same Make sure that other installed plugins don't affect the same
keyword argument and check if metadata is available. keyword argument and check if metadata is available.
@ -67,11 +66,15 @@ class DatabasePlugin(object):
# If no need for a db session, call the route callback # If no need for a db session, call the route callback
return callback return callback
else: else:
# Otherwise, we get a db session and pass it to the callback def wrapper(*args, **kwargs):
with self.get_session() as session: """
kwargs = {} Wrap the callback in a call to get_session.
kwargs[self.KEYWORD] = session """
return functools.partial(callback, **kwargs) with self.get_session() as session:
# Get a db session and pass it to the callback
kwargs[self.KEYWORD] = session
return callback(*args, **kwargs)
return wrapper
Plugin = DatabasePlugin Plugin = DatabasePlugin

View File

@ -0,0 +1,63 @@
import moment from 'moment'
require('es6-promise').polyfill()
require('isomorphic-fetch')
export const getFlats = function (callback) {
fetch('/api/v1/flats')
.then(function (response) {
return response.json()
}).then(function (json) {
const flats = json.data
flats.map(flat => {
if (flat.date) {
flat.date = moment(flat.date)
}
return flat
})
callback(flats)
}).catch(function (ex) {
console.error('Unable to parse flats: ' + ex)
})
}
export const getFlat = function (flatId, callback) {
fetch('/api/v1/flat/' + encodeURIComponent(flatId))
.then(function (response) {
return response.json()
}).then(function (json) {
const flat = json.data
if (flat.date) {
flat.date = moment(flat.date)
}
callback(json.data)
}).catch(function (ex) {
console.error('Unable to parse flats: ' + ex)
})
}
export const updateFlatStatus = function (flatId, newStatus, callback) {
fetch(
'/api/v1/flat/' + encodeURIComponent(flatId) + '/status',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
status: newStatus
})
}
).then(callback)
}
export const getTimeToPlaces = function (callback) {
fetch('/api/v1/time_to/places')
.then(function (response) {
return response.json()
}).then(function (json) {
callback(json.data)
}).catch(function (ex) {
console.error('Unable to fetch time to places: ' + ex)
})
}

View File

@ -0,0 +1,76 @@
<template>
<div>
<h1><router-link :to="{name: 'home'}">Flatisfy</router-link></h1>
<nav>
<ul>
<li><router-link :to="{name: 'home'}">{{ $t("menu.available_flats") }}</router-link></li>
<li><router-link :to="{name: 'followed'}">{{ $t("menu.followed_flats") }}</router-link></li>
<li><router-link :to="{name: 'ignored'}">{{ $t("menu.ignored_flats") }}</router-link></li>
<li><router-link :to="{name: 'user_deleted'}">{{ $t("menu.user_deleted_flats") }}</router-link></li>
</ul>
</nav>
<router-view></router-view>
</div>
</template>
<style>
body {
margin: 0 auto;
max-width: 75em;
font-family: "Helvetica", "Arial", sans-serif;
line-height: 1.5;
padding: 4em 1em;
padding-top: 1em;
color: #555;
}
h1 {
text-align: center;
}
h1,
h2,
strong,
th {
color: #333;
}
table {
border-collapse: collapse;
margin: 1em;
width: calc(100% - 2em);
text-align: center;
}
th, td {
padding: 1em;
border: 1px solid black;
}
tbody>tr:hover {
background-color: #DDD;
}
</style>
<style scoped>
h1 a {
color: inherit;
text-decoration: none;
}
nav {
text-align: center;
}
nav ul {
list-style-position: inside;
padding: 0;
}
nav ul li {
list-style: none;
display: inline-block;
padding-left: 1em;
padding-right: 1em;
}
</style>

View File

@ -0,0 +1,103 @@
<template lang="html">
<div class="full">
<v-map :zoom="zoom.defaultZoom" :center="center" :bounds="bounds" :min-zoom="zoom.minZoom" :max-zoom="zoom.maxZoom">
<v-tilelayer :url="tiles.url" :attribution="tiles.attribution"></v-tilelayer>
<template v-for="marker in flats">
<v-marker :lat-lng="{ lat: marker.gps[0], lng: marker.gps[1] }" :icon="icons.flat">
<v-popup :content="marker.content"></v-popup>
</v-marker>
</template>
<template v-for="(place_gps, place_name) in places">
<v-marker :lat-lng="{ lat: place_gps[0], lng: place_gps[1] }" :icon="icons.place">
<v-tooltip :content="place_name"></v-tooltip>
</v-marker>
</template>
</v-map>
</div>
</template>
<script>
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import markerUrl from 'leaflet/dist/images/marker-icon.png'
import marker2XUrl from 'leaflet/dist/images/marker-icon.png'
import shadowUrl from 'leaflet/dist/images/marker-icon.png'
require('leaflet.icon.glyph')
import Vue2Leaflet from 'vue2-leaflet'
export default {
data () {
return {
center: null,
zoom: {
defaultZoom: 13,
minZoom: 11,
maxZoom: 17
},
tiles: {
url: 'http://{s}.tile.osm.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
},
icons: {
flat: L.icon({
iconUrl: '/static/js/' + markerUrl,
iconRetinaUrl: '/static/js' + marker2XUrl,
shadowUrl: '/static/js' + shadowUrl
}),
place: L.icon.glyph({
prefix: 'fa',
glyph: 'clock-o'
})
}
}
},
components: {
'v-map': Vue2Leaflet.Map,
'v-tilelayer': Vue2Leaflet.TileLayer,
'v-marker': Vue2Leaflet.Marker,
'v-tooltip': Vue2Leaflet.Tooltip,
'v-popup': Vue2Leaflet.Popup
},
computed: {
bounds () {
let bounds = []
this.flats.forEach(flat => bounds.push(flat.gps))
Object.keys(this.places).forEach(place => bounds.push(this.places[place]))
if (bounds.length > 0) {
bounds = L.latLngBounds(bounds)
return bounds
} else {
return null
}
}
},
props: ['flats', 'places']
// TODO: Add a switch to display a layer with isochrones
}
</script>
<style lang="css">
.leaflet-popup-content {
max-height: 20vh;
overflow-y: auto;
}
</style>
<style lang="css" scoped>
.full {
width: 100%;
height: 75vh;
background-color: #ddd;
}
#map {
height: 100%;
}
</style>

View File

@ -0,0 +1,90 @@
<template lang="html">
<table>
<thead>
<tr>
<th>{{ $t("flatsDetails.Title") }}</th>
<th>{{ $t("flatsDetails.Area") }}</th>
<th>{{ $t("flatsDetails.Rooms") }}</th>
<th>{{ $t("flatsDetails.Cost") }}</th>
<th>{{ $t("common.Actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="flat in sortedFlats" :key="flat.id">
<td>
[{{ flat.id.split("@")[1] }}] {{ flat.title }}
<template v-if="flat.photos && flat.photos.length > 0">
<br/>
<img :src="flat.photos[0].url"/>
</template>
</td>
<td>{{ flat.area }} </td>
<td>
{{ flat.rooms ? flat.rooms : '?'}}
</td>
<td>
{{ flat.cost }} {{ flat.currency }}
<template v-if="flat.utilities == 'included'">
{{ $t("flatsDetails.utilities_included") }}
</template>
<template v-else-if="flat.utilities == 'excluded'">
{{ $t("flatsDetails.utilities_excluded") }}
</template>
</td>
<td>
<router-link :to="{name: 'details', params: {id: flat.id}}" :aria-label="$t('common.More')" :title="$t('common.More')">
<i class="fa fa-plus" aria-hidden="true"></i>
</router-link>
<a :href="flat.urls[0]" :aria-label="$t('common.External_link')" :title="$t('common.External_link')" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
<button v-if="flat.status !== 'user_deleted'" v-on:click="updateFlatStatus(flat.id, 'user_deleted')" :aria-label="$t('common.Remove')" :title="$t('common.Remove')">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
<button v-else v-on:click="updateFlatStatus(flat.id, 'new')" :aria-label="$t('common.Restore')" :title="$t('common.Restore')">
<i class="fa fa-undo" aria-hidden="true"></i>
</button>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
props: ['flats'],
computed: {
sortedFlats () {
return this.flats.sort((flat1, flat2) => flat1.cost - flat2.cost)
}
},
methods: {
updateFlatStatus (id, status) {
this.$store.dispatch('updateFlatStatus', { flatId: id, newStatus: status })
}
}
}
</script>
<style scoped>
td a {
display: inline-block;
padding-left: 5px;
padding-right: 5px;
color: inherit;
}
td img {
max-height: 100px;
}
button {
border: none;
background: transparent;
font-size: 1em;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div @keydown="closeModal">
<isotope ref="cpt" :options="isotopeOptions" v-images-loaded:on.progress="layout" :list="photos">
<div v-for="(photo, index) in photos" :key="photo.url">
<img :src="photo.url" v-on:click="openModal(index)"/>
</div>
</isotope>
<div class="modal" ref="modal" :aria-label="$t('slider.Fullscreen_photo')" role="dialog">
<span class="close"><button v-on:click="closeModal" :title="$t('common.Close')" :aria-label="$t('common.Close')">&times;</button></span>
<img class="modal-content" :src="photos[modalImgIndex].url">
</div>
</div>
</template>
<script>
import isotope from 'vueisotope'
import imagesLoaded from 'vue-images-loaded'
export default {
props: [
'photos'
],
components: {
isotope
},
created () {
window.addEventListener('keydown', event => {
if (!this.isModalOpen) {
return
}
if (event.key === 'Escape') {
this.closeModal()
} else if (event.key === 'ArrowLeft') {
this.modalImgIndex = Math.max(
this.modalImgIndex - 1,
0
)
} else if (event.key === 'ArrowRight') {
this.modalImgIndex = Math.min(
this.modalImgIndex + 1,
this.photos.length - 1
)
}
})
},
directives: {
imagesLoaded
},
data () {
return {
'isotopeOptions': {
layoutMode: 'masonry',
masonry: {
columnWidth: 275
}
},
'isModalOpen': false,
'modalImgIndex': 0
}
},
methods: {
layout () {
this.$refs.cpt.layout('masonry')
},
openModal (index) {
this.isModalOpen = true
this.modalImgIndex = index
this.$refs.modal.style.display = 'block'
},
closeModal () {
this.isModalOpen = false
this.$refs.modal.style.display = 'none'
}
}
}
</script>
<style scoped>
.item img {
max-width: 250px;
margin: 10px;
cursor: pointer;
}
.item img:hover {
opacity: 0.7;
}
.modal {
display: none;
position: fixed;
z-index: 10000;
padding-top: 100px;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.9);
}
.modal-content {
margin: auto;
display: block;
height: 80%;
max-width: 700px;
}
.close {
position: absolute;
top: 15px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
}
.close button {
font-size: 1em;
border: none;
background: transparent;
cursor: pointer;
}
.close:hover,
.close:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,55 @@
export default {
common: {
'flats': 'flat | flats',
'loading': 'Loading…',
'Actions': 'Actions',
'More': 'More',
'Remove': 'Remove',
'Restore': 'Restore',
'External_link': 'External link',
'Follow': 'Follow',
'Close': 'Close'
},
home: {
'new_available_flats': 'New available flats'
},
flatListing: {
'no_available_flats': 'No available flats.'
},
menu: {
'available_flats': 'Available flats',
'followed_flats': 'Followed flats',
'ignored_flats': 'Ignored flats',
'user_deleted_flats': 'User deleted flats'
},
flatsDetails: {
'Title': 'Title',
'Area': 'Area',
'Rooms': 'Rooms',
'Cost': 'Cost',
'utilities_included': '(utilities included)',
'utilities_excluded': '(utilities excluded)',
'Description': 'Description',
'Details': 'Details',
'Metadata': 'Metadata',
'postal_code': 'Postal code',
'nearby_stations': 'Nearby stations',
'Times_to': 'Times to',
'Location': 'Location',
'Contact': 'Contact',
'no_phone_found': 'No phone found',
'Original_posts': 'Original posts:',
'Original_post': 'Original post',
'rooms': 'room | rooms',
'bedrooms': 'bedroom | bedrooms'
},
status: {
'new': 'new',
'followed': 'followed',
'ignored': 'ignored',
'user_deleted': 'user deleted'
},
slider: {
'Fullscreen_photo': 'Fullscreen photo'
}
}

View File

@ -0,0 +1,52 @@
import Vue from 'vue'
import VueI18n from 'vue-i18n'
// Import translations
import en from './en'
Vue.use(VueI18n)
export function getBrowserLocales () {
let langs = []
if (navigator.languages) {
// Chrome does not currently set navigator.language correctly
// https://code.google.com/p/chromium/issues/detail?id=101138
// but it does set the first element of navigator.languages correctly
langs = navigator.languages
} else if (navigator.userLanguage) {
// IE only
langs = [navigator.userLanguage]
} else {
// as of this writing the latest version of firefox + safari set this correctly
langs = [navigator.language]
}
// Some browsers does not return uppercase for second part
const locales = langs.map(function (lang) {
const locale = lang.split('-')
return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang
})
return locales
}
const messages = {
'en': en
}
const locales = getBrowserLocales()
var locale = 'en' // Safe default
// Get best matching locale
for (var i = 0; i < locales.length; ++i) {
if (messages[locales[i]]) {
locale = locales[i]
break // Break at first matching locale
}
}
export default new VueI18n({
locale: locale,
messages
})

View File

@ -0,0 +1,14 @@
import Vue from 'vue'
import i18n from './i18n'
import router from './router'
import store from './store'
import App from './components/app.vue'
new Vue({
i18n,
router,
store,
render: createEle => createEle(App)
}).$mount('#app')

View File

@ -0,0 +1,18 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/home.vue'
import Status from '../views/status.vue'
import Details from '../views/details.vue'
Vue.use(VueRouter)
export default new VueRouter({
routes: [
{ path: '/', component: Home, name: 'home' },
{ path: '/followed', component: Status, name: 'followed' },
{ path: '/ignored', component: Status, name: 'ignored' },
{ path: '/user_deleted', component: Status, name: 'user_deleted' },
{ path: '/flat/:id', component: Details, name: 'details' }
]
})

View File

@ -0,0 +1,26 @@
import * as api from '../api'
import * as types from './mutations-types'
export default {
getAllFlats ({ commit }) {
api.getFlats(flats => {
commit(types.REPLACE_FLATS, { flats })
})
},
getFlat ({ commit }, { flatId }) {
api.getFlat(flatId, flat => {
const flats = [flat]
commit(types.MERGE_FLATS, { flats })
})
},
getAllTimeToPlaces ({ commit }) {
api.getTimeToPlaces(timeToPlaces => {
commit(types.RECEIVE_TIME_TO_PLACES, { timeToPlaces })
})
},
updateFlatStatus ({ commit }, { flatId, newStatus }) {
api.updateFlatStatus(flatId, newStatus, response => {
commit(types.UPDATE_FLAT_STATUS, { flatId, newStatus })
})
}
}

View File

@ -0,0 +1,56 @@
import { findFlatGPS } from '../tools'
export default {
allFlats: state => state.flats,
flat: (state, getters) => id => state.flats.find(flat => flat.id === id),
postalCodesFlatsBuckets: (state, getters) => filter => {
const postalCodeBuckets = {}
state.flats.forEach(flat => {
if (filter && filter(flat)) {
const postalCode = flat.flatisfy_postal_code.postal_code
if (!postalCodeBuckets[postalCode]) {
postalCodeBuckets[postalCode] = {
'name': flat.flatisfy_postal_code.name,
'flats': []
}
}
postalCodeBuckets[postalCode].flats.push(flat)
}
})
return postalCodeBuckets
},
flatsMarkers: (state, getters) => (router, filter) => {
const markers = []
state.flats.forEach(flat => {
if (filter && filter(flat)) {
const gps = findFlatGPS(flat)
if (gps) {
const previousMarkerIndex = markers.findIndex(
marker => marker.gps[0] === gps[0] && marker.gps[1] === gps[1]
)
const href = router.resolve({ name: 'details', params: { id: flat.id }}).href
if (previousMarkerIndex !== -1) {
markers[previousMarkerIndex].content += '<br/><a href="' + href + '">' + flat.title + '</a>'
} else {
markers.push({
'title': '',
'content': '<a href="' + href + '">' + flat.title + '</a>',
'gps': gps
})
}
}
}
})
return markers
},
allTimeToPlaces: state => state.timeToPlaces
}

View File

@ -0,0 +1,16 @@
import Vue from 'vue'
import Vuex from 'vuex'
import actions from './actions'
import getters from './getters'
import { state, mutations } from './mutations'
// import products from './modules/products'
Vue.use(Vuex)
export default new Vuex.Store({
state,
actions,
getters,
mutations
})

View File

@ -0,0 +1,4 @@
export const REPLACE_FLATS = 'REPLACE_FLATS'
export const MERGE_FLATS = 'MERGE_FLATS'
export const UPDATE_FLAT_STATUS = 'UPDATE_FLAT_STATUS'
export const RECEIVE_TIME_TO_PLACES = 'RECEIVE_TIME_TO_PLACES'

View File

@ -0,0 +1,34 @@
import Vue from 'vue'
import * as types from './mutations-types'
export const state = {
flats: [],
timeToPlaces: []
}
export const mutations = {
[types.REPLACE_FLATS] (state, { flats }) {
state.flats = flats
},
[types.MERGE_FLATS] (state, { flats }) {
flats.forEach(flat => {
const flatIndex = state.flats.findIndex(storedFlat => storedFlat.id === flat.id)
if (flatIndex > -1) {
Vue.set(state.flats, flatIndex, flat)
} else {
state.flats.push(flat)
}
})
},
[types.UPDATE_FLAT_STATUS] (state, { flatId, newStatus }) {
const index = state.flats.findIndex(flat => flat.id === flatId)
if (index > -1) {
Vue.set(state.flats[index], 'status', newStatus)
}
},
[types.RECEIVE_TIME_TO_PLACES] (state, { timeToPlaces }) {
state.timeToPlaces = timeToPlaces
}
}

View File

@ -0,0 +1,21 @@
export function findFlatGPS (flat) {
let gps
// Try to push a marker based on stations
if (flat.flatisfy_stations && flat.flatisfy_stations.length > 0) {
gps = [0.0, 0.0]
flat.flatisfy_stations.forEach(station => {
gps = [gps[0] + station.gps[0], gps[1] + station.gps[1]]
})
gps = [gps[0] / flat.flatisfy_stations.length, gps[1] / flat.flatisfy_stations.length]
} else {
// Else, push a marker based on postal code
gps = flat.flatisfy_postal_code.gps
}
return gps
}
export function capitalize (string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}

View File

@ -0,0 +1,275 @@
<template>
<div>
<div class="grid" v-if="flat && timeToPlaces">
<div class="left-panel">
<h2>
<a v-on:click="goBack" class="link">
<i class="fa fa-arrow-left" aria-hidden="true"></i>
</a>
({{ flat.status ? capitalize(flat.status) : '' }}) {{ flat.title }} [{{ flat.id.split("@")[1] }}]
</h2>
<div class="grid">
<div class="left-panel">
<p>
{{ flat.cost }} {{ flat.currency }}
<template v-if="flat.utilities === 'included'">
{{ $t("flatsDetails.utilities_included") }}
</template>
<template v-else-if="flat.utilities === 'excluded'">
{{ $t("flatsDetails.utilities_excluded") }}
</template>
</p>
</div>
<p class="right-panel right">
{{ flat.area ? flat.area : '?' }} m<sup>2</sup>,
{{ flat.rooms ? flat.rooms : '?' }} {{ $tc("flatsDetails.rooms", flat.rooms) }} /
{{ flat.bedrooms ? flat.bedrooms : '?' }} {{ $tc("flatsDetails.bedrooms", flat.bedrooms) }}
</p>
</div>
<div>
<template v-if="flat.photos && flat.photos.length > 0">
<Slider :photos="flat.photos"></Slider>
</template>
</div>
<div>
<h3>{{ $t("flatsDetails.Description") }}</h3>
<p>{{ flat.text }}</p>
<p class="right">{{ flat.location }}</p>
<p>First posted {{ flat.date ? flat.date.fromNow() : '?' }}.</p>
</div>
<div>
<h3>{{ $t("flatsDetails.Details") }}</h3>
<table>
<tr v-for="(value, key) in flat.details">
<th>{{ key }}</th>
<td>{{ value }}</td>
</tr>
</table>
</div>
<div>
<h3>{{ $t("flatsDetails.Metadata") }}</h3>
<table>
<tr>
<th>
{{ $t("flatsDetails.postal_code") }}
</th>
<td>
<template v-if="flat.flatisfy_postal_code.postal_code">
{{ flat.flatisfy_postal_code.name }} ( {{ flat.flatisfy_postal_code.postal_code }} )
</template>
<template v-else>
?
</template>
</td>
</tr>
<tr>
<th>
{{ $t("flatsDetails.nearby_stations") }}
</th>
<td>
<template v-if="displayedStations">
{{ displayedStations }}
</template>
<template v-else>
?
</template>
</td>
</tr>
<tr>
<th>
{{ $t("flatsDetails.Times_to") }}
</th>
<td>
<template v-if="Object.keys(flat.flatisfy_time_to).length">
<ul class="time_to_list">
<li v-for="(time_to, place) in flat.flatisfy_time_to" :key="place">
{{ place }}: {{ time_to }}
</li>
</ul>
</template>
<template v-else>
?
</template>
</td>
</tr>
</table>
</div>
<div>
<h3>{{ $t("flatsDetails.Location") }}</h3>
<FlatsMap :flats="flatMarkers" :places="timeToPlaces"></FlatsMap>
</div>
</div>
<div class="right-panel">
<h3>{{ $t("flatsDetails.Contact") }}</h3>
<div class="contact">
<p>
<a v-if="flat.phone" :href="'tel:+33' + flat.phone">{{ flat.phone }}</a>
<template v-else>
{{ $t("flatsDetails.no_phone_found") }}
</template>
</p>
<p>{{ $t("flatsDetails.Original_posts") }}
<ul>
<li v-for="(url, index) in flat.urls">
<a :href="url">
{{ $t("flatsDetails.Original_post") }} {{ index + 1 }}
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</li>
</ul>
</p>
</div>
<h3>{{ $t("common.Actions") }}</h3>
<nav>
<ul>
<template v-if="flat.status !== 'user_deleted'">
<li>
<button v-on:click="updateFlatStatus('follow')">
<i class="fa fa-star" aria-hidden="true"></i>
{{ $t("common.Follow") }}
</button>
</li>
<li>
<button v-on:click="updateFlatStatus('user_deleted')">
<i class="fa fa-trash" aria-hidden="true"></i>
{{ $t("common.Remove") }}
</button>
</li>
</template>
<template v-else>
<li>
<button v-on:click="updateFlatStatus('new')">
<i class="fa fa-undo" aria-hidden="true"></i>
{{ $t("common.Restore") }}
</button>
</li>
</template>
</ul>
</nav>
</div>
</div>
<template v-else>
<p>{{ $t("common.loading") }}</p>
</template>
</div>
</template>
<script>
import FlatsMap from '../components/flatsmap.vue'
import Slider from '../components/slider.vue'
import { capitalize } from '../tools'
export default {
components: {
FlatsMap,
Slider
},
created () {
// Fetch data when the component is created
this.fetchData()
// Scrolls to top when view is displayed
window.scrollTo(0, 0)
},
watch: {
// Fetch data again when the component is updated
'$route': 'fetchData'
},
computed: {
flatMarkers () {
return this.$store.getters.flatsMarkers(this.$router, flat => flat.id === this.$route.params.id)
},
timeToPlaces () {
return this.$store.getters.allTimeToPlaces
},
flat () {
return this.$store.getters.flat(this.$route.params.id)
},
displayedStations () {
if (this.flat.flatisfy_stations.length > 0) {
const stationsNames = this.flat.flatisfy_stations.map(station => station.name)
return stationsNames.join(', ')
} else {
return null
}
}
},
methods: {
fetchData () {
this.$store.dispatch('getFlat', { flatId: this.$route.params.id })
this.$store.dispatch('getAllTimeToPlaces')
},
goBack () {
return this.$router.go(-1)
},
updateFlatStatus (status) {
this.$store.dispatch('updateFlatStatus', { flatId: this.$route.params.id, newStatus: status })
},
capitalize: capitalize
}
}
</script>
<style scoped>
.grid {
display: grid;
grid-gap: 50px;
}
.left-panel {
grid-column: 1;
grid-row: 1;
}
.right-panel {
grid-column: 2;
grid-row: 1;
}
.right {
text-align: right;
}
nav ul {
list-style-type: none;
padding-left: 1em;
}
.contact {
padding-left: 1em;
}
.link {
cursor: pointer;
}
.right-panel li {
margin-bottom: 1em;
margin-top: 1em;
}
button {
cursor: pointer;
width: 75%;
padding: 0.3em;
font-size: 0.9em;
}
.time_to_list {
margin: 0;
padding-left: 0;
list-style-position: outside;
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div>
<template v-if="postalCodesFlatsBuckets && flatsMarkers">
<FlatsMap :flats="flatsMarkers" :places="timeToPlaces"></FlatsMap>
<h2>{{ $t("home.new_available_flats") }}</h2>
<template v-if="postalCodesFlatsBuckets">
<template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
<h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", 42) }}</h3>
<FlatsTable :flats="postal_code_data.flats"></FlatsTable>
</template>
</template>
<template v-else>
<p>{{ $t("flatListing.no_available_flats") }}</p>
</template>
</template>
<template v-else>
<p>{{ $t("common.loading") }}</p>
</template>
</div>
</template>
<script>
import FlatsMap from '../components/flatsmap.vue'
import FlatsTable from '../components/flatstable.vue'
export default {
components: {
FlatsMap,
FlatsTable
},
created () {
// Fetch flats when the component is created
this.$store.dispatch('getAllFlats')
// Fetch time to places when the component is created
this.$store.dispatch('getAllTimeToPlaces')
},
computed: {
postalCodesFlatsBuckets () {
return this.$store.getters.postalCodesFlatsBuckets(flat => flat.status === 'new')
},
flatsMarkers () {
return this.$store.getters.flatsMarkers(this.$router, flat => flat.status === 'new')
},
timeToPlaces () {
return this.$store.getters.allTimeToPlaces
}
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<div>
<h2>{{ capitalize($t("status." + $route.name)) }}</h2>
<template v-if="Object.keys(postalCodesFlatsBuckets).length">
<template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
<h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", 42) }}</h3>
<FlatsTable :flats="postal_code_data.flats"></FlatsTable>
</template>
</template>
<template v-else>
<p>{{ $t("flatListing.no_available_flats") }}</p>
</template>
</div>
</template>
<script>
import { capitalize } from '../tools'
import FlatsTable from '../components/flatstable.vue'
export default {
components: {
FlatsTable
},
created () {
// Fetch flats when the component is created
this.$store.dispatch('getAllFlats')
},
computed: {
postalCodesFlatsBuckets () {
return this.$store.getters.postalCodesFlatsBuckets(flat => flat.status === this.$route.name)
}
},
methods: {
capitalize: capitalize
}
}
</script>

View File

@ -6,6 +6,11 @@ from __future__ import (
absolute_import, division, print_function, unicode_literals absolute_import, division, print_function, unicode_literals
) )
import json
import bottle
import flatisfy.data
from flatisfy.models import flat as flat_model from flatisfy.models import flat as flat_model
@ -20,28 +25,136 @@ def index_v1():
} }
def flats_v1(db): def flats_v1(config, db):
""" """
API v1 flats route: API v1 flats route:
GET /api/v1/flats GET /api/v1/flats
:return: The available flats objects in a JSON ``data`` dict.
""" """
postal_codes = flatisfy.data.load_data("postal_codes", config)
flats = [ flats = [
flat.json_api_repr() flat.json_api_repr()
for flat in db.query(flat_model.Flat).all() for flat in db.query(flat_model.Flat).all()
] ]
for flat in flats:
if flat["flatisfy_postal_code"]:
postal_code_data = postal_codes[flat["flatisfy_postal_code"]]
flat["flatisfy_postal_code"] = {
"postal_code": flat["flatisfy_postal_code"],
"name": postal_code_data["nom"],
"gps": postal_code_data["gps"]
}
else:
flat["flatisfy_postal_code"] = {}
return { return {
"data": flats "data": flats
} }
def flat_v1(flat_id, db): def flats_by_status_v1(status, db):
"""
API v1 flats route with a specific status:
GET /api/v1/flats/status/:status
:return: The matching flats objects in a JSON ``data`` dict.
"""
try:
flats = [
flat.json_api_repr()
for flat in (
db.query(flat_model.Flat)
.filter_by(status=getattr(flat_model.FlatStatus, status))
.all()
)
]
except AttributeError:
return bottle.HTTPError(400, "Invalid status provided.")
return {
"data": flats
}
def flat_v1(flat_id, config, db):
""" """
API v1 flat route: API v1 flat route:
GET /api/v1/flat/:flat_id GET /api/v1/flat/:flat_id
:return: The flat object in a JSON ``data`` dict.
"""
postal_codes = flatisfy.data.load_data("postal_codes", config)
flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()
if not flat:
return bottle.HTTPError(404, "No flat with id {}.".format(flat_id))
flat = flat.json_api_repr()
if flat["flatisfy_postal_code"]:
postal_code_data = postal_codes[flat["flatisfy_postal_code"]]
flat["flatisfy_postal_code"] = {
"postal_code": flat["flatisfy_postal_code"],
"name": postal_code_data["nom"],
"gps": postal_code_data["gps"]
}
else:
flat["flatisfy_postal_code"] = {}
return {
"data": flat
}
def update_flat_status_v1(flat_id, db):
"""
API v1 route to update flat status:
POST /api/v1/flat/:flat_id/status
Data: {
"status": "NEW_STATUS"
}
:return: The new flat object in a JSON ``data`` dict.
""" """
flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()
if not flat:
return bottle.HTTPError(404, "No flat with id {}.".format(flat_id))
try:
flat.status = getattr(
flat_model.FlatStatus, json.load(bottle.request.body)["status"]
)
except (AttributeError, ValueError, KeyError):
return bottle.HTTPError(400, "Invalid status provided.")
json_flat = flat.json_api_repr()
return { return {
"data": flat.json_api_repr() "data": json_flat
}
def time_to_places_v1(config):
"""
API v1 route to fetch the details of the places to compute time to.
GET /api/v1/time_to/places
:return: The JSON dump of the places to compute time to (dict of places
names mapped to GPS coordinates).
"""
places = {
k: v["gps"]
for k, v in config["constraints"]["time_to"].items()
}
return {
"data": places
} }

View File

@ -2,29 +2,13 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<title>Flatisfy</title> <title>Flatisfy</title>
<script src="https://unpkg.com/vue"></script>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app"></div>
<h1>Flatisfy</h1> <script src="static/js/bundle.js"></script>
<table>
<thead>
<tr>
<th>Titre</th>
<th>Lien</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<script type="text/javascript">
var app = new Vue({
el: '#app',
data: {
}
})
</script>
</body> </body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,3 +1,4 @@
#!/bin/sh #!/bin/sh
pylint --rcfile=.ci/pylintrc flatisfy pylint --rcfile=.ci/pylintrc flatisfy
npm run lint

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "Flatisfy",
"description": "Flatisfy is your new companion to ease your search of a new housing :)",
"author": "Phyks (Lucas Verney) <phyks@phyks.me>",
"license": "MIT",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://Phyks@git.phyks.me/Phyks/flatisfy.git"
},
"homepage": "https://git.phyks.me/Phyks/flatisfy",
"scripts": {
"build": "webpack --colors --progress",
"watch": "webpack --colors --progress --watch",
"lint": "eslint --ext .js,.vue ./flatisfy/web/js_src/**"
},
"dependencies": {
"es6-promise": "^4.1.0",
"imagesloaded": "^4.1.1",
"isomorphic-fetch": "^2.2.1",
"isotope-layout": "^3.0.3",
"leaflet.icon.glyph": "^0.2.0",
"masonry": "0.0.2",
"moment": "^2.18.1",
"vue": "^2.2.6",
"vue-i18n": "^6.1.1",
"vue-images-loaded": "^1.1.2",
"vue-router": "^2.4.0",
"vue2-leaflet": "0.0.44",
"vueisotope": "^3.0.0-rc",
"vuex": "^2.3.0"
},
"devDependencies": {
"babel-core": "^6.24.1",
"babel-loader": "^6.4.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"css-loader": "^0.28.0",
"eslint": "^3.19.0",
"eslint-config-vue": "^2.0.2",
"eslint-plugin-vue": "^2.0.1",
"file-loader": "^0.11.1",
"image-webpack-loader": "^3.3.0",
"style-loader": "^0.16.1",
"vue-html-loader": "^1.2.4",
"vue-loader": "^11.3.4",
"vue-template-compiler": "^2.2.6",
"webpack": "^2.3.3"
}
}

View File

@ -1,6 +1,8 @@
appdirs appdirs
arrow
bottle bottle
bottle-sqlalchemy bottle-sqlalchemy
canister
enum34 enum34
future future
request request

55
webpack.config.js Normal file
View File

@ -0,0 +1,55 @@
module.exports = {
entry: './flatisfy/web/js_src/main.js',
output: {
path: __dirname + '/flatisfy/web/static/js/',
filename: 'bundle.js'
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
js: 'babel-loader'
}
}
},
{
test: /\.css$/,
loader: 'style-loader!css-loader'
},
{
test: /\.(jpe?g|png|gif|svg)$/i,
loaders: [
'file-loader?hash=sha512&digest=hex&name=[hash].[ext]',
{
loader: 'image-webpack-loader',
query: {
bypassOnDebug: true,
'optipng': {
optimizationLevel: 7
},
'gifsicle': {
interlaced: false
}
}
}
]
}
]
},
resolve: {
alias: {
'masonry': 'masonry-layout',
'isotope': 'isotope-layout'
}
}
}

33
wsgi.py Normal file
View File

@ -0,0 +1,33 @@
# coding: utf-8
"""
Expose a WSGI-compatible application to serve with a webserver.
"""
from __future__ import absolute_import, print_function, unicode_literals
import logging
import os
import sys
import flatisfy.config
from flatisfy.web import app as web_app
class Args():
config = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"config/config.json"
)
LOGGER = logging.getLogger("flatisfy")
CONFIG = flatisfy.config.load_config(Args())
if CONFIG is None:
LOGGER.error("Invalid configuration. Exiting. "
"Run init-config before if this is the first time "
"you run Flatisfy.")
sys.exit(1)
application = app = web_app.get_app(CONFIG)