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:
parent
4966fe2111
commit
a57d9ce8e3
4
.babelrc
Normal file
4
.babelrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["es2015", "stage-0"],
|
||||
"plugins": ["transform-runtime"]
|
||||
}
|
10
.eslintrc
Normal file
10
.eslintrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
extends: ["vue", /* your other extends */],
|
||||
plugins: ["vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
rules: {
|
||||
'indent': ["error", 4, { 'SwitchCase': 1 }],
|
||||
}
|
||||
}
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
build
|
||||
*.json
|
||||
*.pyc
|
||||
*.swp
|
||||
*.swo
|
||||
*.db
|
||||
config/
|
||||
node_modules
|
||||
flatisfy/web/static/js
|
||||
|
46
CONTRIBUTING.md
Normal file
46
CONTRIBUTING.md
Normal 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!
|
@ -104,6 +104,11 @@ The content of this repository is licensed under an MIT license, unless
|
||||
explicitly mentionned otherwise.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
See the `CONTRIBUTING.md` file for more infos.
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
* [Weboob](http://weboob.org/)
|
||||
|
@ -90,6 +90,10 @@ def parse_args(argv=None):
|
||||
subparsers.add_parser("import", parents=[parent_parser],
|
||||
help="Import housing posts in database.")
|
||||
|
||||
# Purge subcommand parser
|
||||
subparsers.add_parser("purge", parents=[parent_parser],
|
||||
help="Purge database.")
|
||||
|
||||
# Serve subcommand parser
|
||||
parser_serve = subparsers.add_parser("serve", parents=[parent_parser],
|
||||
help="Serve the web app.")
|
||||
@ -103,6 +107,7 @@ def main():
|
||||
"""
|
||||
Main module code.
|
||||
"""
|
||||
# pylint: disable=locally-disabled,too-many-branches
|
||||
# Parse arguments
|
||||
args = parse_args()
|
||||
|
||||
@ -163,7 +168,12 @@ def main():
|
||||
)
|
||||
# Import command
|
||||
elif args.cmd == "import":
|
||||
# TODO: Do not fetch details for already imported flats / use the last
|
||||
# timestamp
|
||||
cmds.import_and_filter(config)
|
||||
# Purge command
|
||||
elif args.cmd == "purge":
|
||||
cmds.purge_db(config)
|
||||
# Serve command
|
||||
elif args.cmd == "serve":
|
||||
cmds.serve(config)
|
||||
|
@ -4,6 +4,8 @@ Main commands available for flatisfy.
|
||||
"""
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
import flatisfy.filters
|
||||
from flatisfy import database
|
||||
from flatisfy.models import flat as flat_model
|
||||
@ -12,6 +14,9 @@ from flatisfy import tools
|
||||
from flatisfy.web import app as web_app
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_and_filter(config):
|
||||
"""
|
||||
Fetch the available flats list. Then, filter it according to criteria.
|
||||
@ -34,9 +39,9 @@ def fetch_and_filter(config):
|
||||
# additional infos
|
||||
if config["passes"] > 1:
|
||||
# Load additional infos
|
||||
for flat in flats_list:
|
||||
details = fetch.fetch_details(flat["id"])
|
||||
flat = tools.merge_dicts(flat, details)
|
||||
for i, flat in enumerate(flats_list):
|
||||
details = fetch.fetch_details(config, flat["id"])
|
||||
flats_list[i] = tools.merge_dicts(flat, details)
|
||||
|
||||
flats_list, extra_ignored_flats = flatisfy.filters.second_pass(
|
||||
flats_list, config
|
||||
@ -83,7 +88,7 @@ def import_and_filter(config):
|
||||
:return: ``None``.
|
||||
"""
|
||||
# Fetch and filter flats list
|
||||
flats_list, purged_list = fetch_and_filter(config)
|
||||
flats_list, ignored_list = fetch_and_filter(config)
|
||||
# Create database connection
|
||||
get_session = database.init_db(config["database"])
|
||||
|
||||
@ -92,12 +97,27 @@ def import_and_filter(config):
|
||||
flat = flat_model.Flat.from_dict(flat_dict)
|
||||
session.merge(flat)
|
||||
|
||||
for flat_dict in purged_list:
|
||||
for flat_dict in ignored_list:
|
||||
flat = flat_model.Flat.from_dict(flat_dict)
|
||||
flat.status = flat_model.FlatStatus.purged
|
||||
flat.status = flat_model.FlatStatus.ignored
|
||||
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):
|
||||
"""
|
||||
Serve the web app.
|
||||
@ -106,5 +126,11 @@ def serve(config):
|
||||
:return: ``None``, long-running process.
|
||||
"""
|
||||
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)
|
||||
|
@ -21,10 +21,11 @@ from flatisfy import tools
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_CONFIG = {
|
||||
# Flatboob queries to fetch
|
||||
"queries": [],
|
||||
# Constraints to match
|
||||
"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
|
||||
"area": (None, None), # (min, max) in m^2
|
||||
"cost": (None, None), # (min, max) in currency unit
|
||||
@ -42,12 +43,18 @@ DEFAULT_CONFIG = {
|
||||
"max_entries": None,
|
||||
# Directory in wich data will be put. ``None`` is XDG default location.
|
||||
"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
|
||||
"database": None,
|
||||
# Web app port
|
||||
"port": 8080,
|
||||
# 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__)
|
||||
@ -68,7 +75,7 @@ def validate_config(config):
|
||||
assert all(
|
||||
x is None or
|
||||
(
|
||||
(isinstance(x, int) or isinstance(x, float)) and
|
||||
isinstance(x, (float, int)) and
|
||||
x >= 0
|
||||
)
|
||||
for x in bounds
|
||||
@ -81,9 +88,19 @@ def validate_config(config):
|
||||
# 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
|
||||
# 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 len(config["constraints"]["postal_codes"]) > 0
|
||||
assert config["constraints"]["postal_codes"]
|
||||
|
||||
assert "area" in config["constraints"]
|
||||
_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["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 isinstance(config["port"], int)
|
||||
assert isinstance(config["host"], str)
|
||||
assert config["webserver"] is None or isinstance(config["webserver"], str) # noqa: E501
|
||||
|
||||
return True
|
||||
except (AssertionError, KeyError):
|
||||
@ -140,10 +159,11 @@ def load_config(args=None):
|
||||
try:
|
||||
with open(args.config, "r") as fh:
|
||||
config_data.update(json.load(fh))
|
||||
except (IOError, ValueError):
|
||||
except (IOError, ValueError) as exc:
|
||||
LOGGER.error(
|
||||
"Unable to load configuration from file, "
|
||||
"using default configuration."
|
||||
"using default configuration: %s.",
|
||||
exc
|
||||
)
|
||||
|
||||
# Overload config with arguments
|
||||
@ -188,7 +208,6 @@ def load_config(args=None):
|
||||
if config_validation is True:
|
||||
LOGGER.info("Config has been fully initialized.")
|
||||
return config_data
|
||||
else:
|
||||
LOGGER.error("Error in configuration: %s.", config_validation)
|
||||
return None
|
||||
|
||||
|
@ -9,6 +9,7 @@ import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import flatisfy.exceptions
|
||||
|
||||
@ -157,7 +158,7 @@ def load_data(data_type, config):
|
||||
LOGGER.error("Invalid JSON data file: %s.", datafile_path)
|
||||
return None
|
||||
|
||||
if len(data) == 0:
|
||||
if not data:
|
||||
LOGGER.warning("Loading empty data for %s.", data_type)
|
||||
|
||||
return data
|
||||
|
@ -41,16 +41,18 @@ def init_db(database_uri=None):
|
||||
|
||||
engine = create_engine(database_uri)
|
||||
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
|
||||
def get_session():
|
||||
# pylint: disable=locally-disabled,line-too-long
|
||||
"""
|
||||
Provide a transactional scope around a series of operations.
|
||||
|
||||
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.
|
||||
"""
|
||||
# pylint: enable=line-too-long,locally-disabled
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
|
@ -46,5 +46,5 @@ class StringyJSON(types.TypeDecorator):
|
||||
|
||||
# TypeEngine.with_variant says "use StringyJSON instead when
|
||||
# connecting to 'sqlite'"
|
||||
# pylint: disable=invalid-name
|
||||
# pylint: disable=locally-disabled,invalid-name
|
||||
MagicJSON = types.JSON().with_variant(StringyJSON, 'sqlite')
|
||||
|
@ -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
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from flatisfy import data
|
||||
from flatisfy import tools
|
||||
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
flatboob_output = subprocess.check_output(
|
||||
["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob",
|
||||
"-n", str(max_entries), "-f", "json", "load", query]
|
||||
with WeboobProxy(config) as weboob_proxy:
|
||||
LOGGER.info("Loading flats...")
|
||||
queries = weboob_proxy.build_queries(config["constraints"])
|
||||
housing_posts = []
|
||||
for query in queries:
|
||||
housing_posts.extend(
|
||||
weboob_proxy.query(query, config["max_entries"])
|
||||
)
|
||||
query_flats_list = json.loads(flatboob_output)
|
||||
LOGGER.info("Fetched %d flats.", len(query_flats_list))
|
||||
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
|
||||
|
||||
|
||||
def fetch_details(flat_id):
|
||||
def fetch_details(config, flat_id):
|
||||
"""
|
||||
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.
|
||||
:return: A flat dict with all the available data.
|
||||
"""
|
||||
with WeboobProxy(config) as weboob_proxy:
|
||||
LOGGER.info("Loading additional details for flat %s.", flat_id)
|
||||
flatboob_output = subprocess.check_output(
|
||||
["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob",
|
||||
"-f", "json", "info", flat_id]
|
||||
)
|
||||
flat_details = json.loads(flatboob_output)
|
||||
LOGGER.info("Fetched details for flat %s.", flat_id)
|
||||
weboob_output = weboob_proxy.info(flat_id)
|
||||
|
||||
if flat_details:
|
||||
flat_details = flat_details[0]
|
||||
flat_details = json.loads(weboob_output)
|
||||
LOGGER.info("Fetched details for flat %s.", flat_id)
|
||||
|
||||
return flat_details
|
||||
|
||||
|
@ -89,9 +89,10 @@ def first_pass(flats_list, config):
|
||||
|
||||
:param flats_list: A list of flats dict to filter.
|
||||
: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.")
|
||||
|
||||
# Handle duplicates based on ids
|
||||
# Just remove them (no merge) as they should be the exact same object.
|
||||
flats_list = duplicates.detect(
|
||||
@ -105,16 +106,16 @@ def first_pass(flats_list, config):
|
||||
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)
|
||||
# Guess the postal codes
|
||||
flats_list = metadata.guess_postal_code(flats_list, config)
|
||||
# Try to match with stations
|
||||
flats_list = metadata.guess_stations(flats_list, config)
|
||||
# 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):
|
||||
@ -130,7 +131,7 @@ def second_pass(flats_list, config):
|
||||
|
||||
:param flats_list: A list of flats dict to filter.
|
||||
: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.")
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
@ -5,9 +5,23 @@ Filtering functions to detect and merge duplicates.
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import collections
|
||||
import logging
|
||||
|
||||
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):
|
||||
"""
|
||||
@ -27,7 +41,6 @@ def detect(flats_list, key="id", merge=True):
|
||||
|
||||
:return: A deduplicated list of flat dicts.
|
||||
"""
|
||||
# TODO: Keep track of found duplicates?
|
||||
# ``seen`` is a dict mapping aggregating the flats by the deduplication
|
||||
# keys. We basically make buckets of flats for every key value. Flats in
|
||||
# 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.
|
||||
unique_flats_list.extend(matching_flats)
|
||||
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
|
||||
if merge:
|
||||
# If a merge is requested, do the merge
|
||||
|
@ -20,14 +20,20 @@ LOGGER = logging.getLogger(__name__)
|
||||
def init(flats_list):
|
||||
"""
|
||||
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.
|
||||
:return: The updated list
|
||||
"""
|
||||
for flat in flats_list:
|
||||
# Init flatisfy key
|
||||
if "flatisfy" not in flat:
|
||||
flat["flatisfy"] = {}
|
||||
# Move url key to urls
|
||||
flat["urls"] = [flat["url"]]
|
||||
# Create merged_ids key
|
||||
flat["merged_ids"] = [flat["id"]]
|
||||
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,
|
||||
# display some warning to the user
|
||||
if (
|
||||
"matched_stations" in flat["flatisfy"]["matched_stations"] and
|
||||
"matched_stations" in flat["flatisfy"] and
|
||||
(
|
||||
# Do a set comparison, as ordering is not important
|
||||
set(flat["flatisfy"]["matched_stations"]) !=
|
||||
set(good_matched_stations)
|
||||
set([
|
||||
station["name"]
|
||||
for station in flat["flatisfy"]["matched_stations"]
|
||||
]) !=
|
||||
set([
|
||||
station["name"]
|
||||
for station in good_matched_stations
|
||||
])
|
||||
)
|
||||
):
|
||||
LOGGER.warning(
|
||||
|
@ -2,9 +2,12 @@
|
||||
"""
|
||||
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
|
||||
|
||||
import logging
|
||||
|
||||
import arrow
|
||||
import enum
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
An enum of the possible status for a flat entry.
|
||||
"""
|
||||
purged = -10
|
||||
user_deleted = -100
|
||||
ignored = -10
|
||||
new = 0
|
||||
contacted = 10
|
||||
answer_no = 20
|
||||
answer_yes = 21
|
||||
followed = 10
|
||||
contacted = 20
|
||||
answer_no = 30
|
||||
answer_yes = 31
|
||||
|
||||
|
||||
class Flat(BASE):
|
||||
@ -36,6 +53,7 @@ class Flat(BASE):
|
||||
bedrooms = Column(Float)
|
||||
cost = Column(Float)
|
||||
currency = Column(String)
|
||||
utilities = Column(Enum(FlatUtilities), default=FlatUtilities.unknown)
|
||||
date = Column(DateTime)
|
||||
details = Column(MagicJSON)
|
||||
location = Column(String)
|
||||
@ -45,7 +63,8 @@ class Flat(BASE):
|
||||
station = Column(String)
|
||||
text = Column(Text)
|
||||
title = Column(String)
|
||||
url = Column(String)
|
||||
urls = Column(MagicJSON)
|
||||
merged_ids = Column(MagicJSON)
|
||||
|
||||
# Flatisfy data
|
||||
# TODO: Should be in another table with relationships
|
||||
@ -65,25 +84,45 @@ class Flat(BASE):
|
||||
# Handle flatisfy metadata
|
||||
flat_dict = flat_dict.copy()
|
||||
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"].get("postal_code", None)
|
||||
)
|
||||
flat_dict["flatisfy_time_to"] = (
|
||||
flat_dict["flatisfy"].get("time_to", None)
|
||||
flat_dict["flatisfy"].get("time_to", {})
|
||||
)
|
||||
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
|
||||
flat_dict["date"] = None # TODO
|
||||
flat_dict["date"] = arrow.get(flat_dict["date"]).naive
|
||||
|
||||
flat_object = Flat()
|
||||
flat_object.__dict__.update(flat_dict)
|
||||
return flat_object
|
||||
|
||||
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):
|
||||
@ -96,6 +135,9 @@ class Flat(BASE):
|
||||
for k, v in self.__dict__.items()
|
||||
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
|
||||
|
@ -8,6 +8,7 @@ from __future__ import (
|
||||
)
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@ -23,6 +24,16 @@ LOGGER = logging.getLogger(__name__)
|
||||
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):
|
||||
"""
|
||||
Pretty JSON output.
|
||||
@ -38,10 +49,25 @@ def pretty_json(data):
|
||||
"toto": "ok"
|
||||
}
|
||||
"""
|
||||
return json.dumps(data, indent=4, separators=(',', ': '),
|
||||
return json.dumps(data, cls=DateAwareJSONEncoder,
|
||||
indent=4, separators=(',', ': '),
|
||||
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):
|
||||
"""
|
||||
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])
|
||||
long2 = math.radians(gps2[1])
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
# pylint: disable=locally-disabled,invalid-name
|
||||
a = (
|
||||
math.sin((lat2 - lat1) / 2.0)**2 +
|
||||
math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0)**2
|
||||
@ -175,21 +201,29 @@ def merge_dicts(*args):
|
||||
"""
|
||||
if len(args) == 1:
|
||||
return args[0]
|
||||
else:
|
||||
flat1, flat2 = args[:2]
|
||||
|
||||
flat1, flat2 = args[:2] # pylint: disable=locally-disabled,unbalanced-tuple-unpacking,line-too-long
|
||||
merged_flat = {}
|
||||
for k, value2 in flat2.items():
|
||||
value1 = flat1.get(k, None)
|
||||
if value1 is None:
|
||||
|
||||
if k in ["urls", "merged_ids"]:
|
||||
# Handle special fields separately
|
||||
merged_flat[k] = list(set(value2 + value1))
|
||||
continue
|
||||
|
||||
if not value1:
|
||||
# flat1 has empty matching field, just keep the flat2 field
|
||||
merged_flat[k] = value2
|
||||
elif value2 is None:
|
||||
elif not value2:
|
||||
# flat2 field is empty, just keep the flat1 field
|
||||
merged_flat[k] = value1
|
||||
else:
|
||||
# Any other case, we should merge
|
||||
# TODO: Do the merge
|
||||
merged_flat[k] = value1
|
||||
# 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:])
|
||||
|
||||
|
||||
|
@ -6,15 +6,30 @@ from __future__ import (
|
||||
absolute_import, division, print_function, unicode_literals
|
||||
)
|
||||
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import bottle
|
||||
import canister
|
||||
|
||||
from flatisfy import database
|
||||
from flatisfy.tools import DateAwareJSONEncoder
|
||||
from flatisfy.web.routes import api as api_routes
|
||||
from flatisfy.web.configplugin import ConfigPlugin
|
||||
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):
|
||||
"""
|
||||
Helper function to serve static file.
|
||||
@ -38,11 +53,31 @@ def get_app(config):
|
||||
|
||||
app = bottle.default_app()
|
||||
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
|
||||
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/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/status", "POST",
|
||||
api_routes.update_flat_status_v1)
|
||||
|
||||
# Index
|
||||
app.route("/", "GET", lambda: _serve_static_file("index.html"))
|
||||
|
72
flatisfy/web/configplugin.py
Normal file
72
flatisfy/web/configplugin.py
Normal 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
|
@ -28,13 +28,12 @@ class DatabasePlugin(object):
|
||||
|
||||
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
|
||||
'sessionmaker' function. Will create its own if undefined.
|
||||
"""
|
||||
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
|
||||
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
|
||||
return callback
|
||||
else:
|
||||
# Otherwise, we get a db session and pass it to the callback
|
||||
def wrapper(*args, **kwargs):
|
||||
"""
|
||||
Wrap the callback in a call to get_session.
|
||||
"""
|
||||
with self.get_session() as session:
|
||||
kwargs = {}
|
||||
# Get a db session and pass it to the callback
|
||||
kwargs[self.KEYWORD] = session
|
||||
return functools.partial(callback, **kwargs)
|
||||
return callback(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
Plugin = DatabasePlugin
|
||||
|
63
flatisfy/web/js_src/api/index.js
Normal file
63
flatisfy/web/js_src/api/index.js
Normal 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)
|
||||
})
|
||||
}
|
76
flatisfy/web/js_src/components/app.vue
Normal file
76
flatisfy/web/js_src/components/app.vue
Normal 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>
|
103
flatisfy/web/js_src/components/flatsmap.vue
Normal file
103
flatisfy/web/js_src/components/flatsmap.vue
Normal 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: '© <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>
|
90
flatisfy/web/js_src/components/flatstable.vue
Normal file
90
flatisfy/web/js_src/components/flatstable.vue
Normal 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 }} m²</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>
|
144
flatisfy/web/js_src/components/slider.vue
Normal file
144
flatisfy/web/js_src/components/slider.vue
Normal 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')">×</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>
|
55
flatisfy/web/js_src/i18n/en/index.js
Normal file
55
flatisfy/web/js_src/i18n/en/index.js
Normal 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'
|
||||
}
|
||||
}
|
52
flatisfy/web/js_src/i18n/index.js
Normal file
52
flatisfy/web/js_src/i18n/index.js
Normal 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
|
||||
})
|
14
flatisfy/web/js_src/main.js
Normal file
14
flatisfy/web/js_src/main.js
Normal 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')
|
18
flatisfy/web/js_src/router/index.js
Normal file
18
flatisfy/web/js_src/router/index.js
Normal 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' }
|
||||
]
|
||||
})
|
26
flatisfy/web/js_src/store/actions.js
Normal file
26
flatisfy/web/js_src/store/actions.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
56
flatisfy/web/js_src/store/getters.js
Normal file
56
flatisfy/web/js_src/store/getters.js
Normal 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
|
||||
}
|
16
flatisfy/web/js_src/store/index.js
Normal file
16
flatisfy/web/js_src/store/index.js
Normal 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
|
||||
})
|
4
flatisfy/web/js_src/store/mutations-types.js
Normal file
4
flatisfy/web/js_src/store/mutations-types.js
Normal 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'
|
34
flatisfy/web/js_src/store/mutations.js
Normal file
34
flatisfy/web/js_src/store/mutations.js
Normal 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
|
||||
}
|
||||
}
|
21
flatisfy/web/js_src/tools/index.js
Normal file
21
flatisfy/web/js_src/tools/index.js
Normal 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)
|
||||
}
|
275
flatisfy/web/js_src/views/details.vue
Normal file
275
flatisfy/web/js_src/views/details.vue
Normal 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>
|
52
flatisfy/web/js_src/views/home.vue
Normal file
52
flatisfy/web/js_src/views/home.vue
Normal 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>
|
41
flatisfy/web/js_src/views/status.vue
Normal file
41
flatisfy/web/js_src/views/status.vue
Normal 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>
|
@ -6,6 +6,11 @@ from __future__ import (
|
||||
absolute_import, division, print_function, unicode_literals
|
||||
)
|
||||
|
||||
import json
|
||||
|
||||
import bottle
|
||||
|
||||
import flatisfy.data
|
||||
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:
|
||||
|
||||
GET /api/v1/flats
|
||||
|
||||
:return: The available flats objects in a JSON ``data`` dict.
|
||||
"""
|
||||
postal_codes = flatisfy.data.load_data("postal_codes", config)
|
||||
|
||||
flats = [
|
||||
flat.json_api_repr()
|
||||
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 {
|
||||
"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:
|
||||
|
||||
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()
|
||||
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 {
|
||||
"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
|
||||
}
|
||||
|
@ -2,29 +2,13 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>Flatisfy</h1>
|
||||
<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>
|
||||
<div id="app"></div>
|
||||
<script src="static/js/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
BIN
flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png
Normal file
BIN
flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 535 B |
BIN
flatisfy/web/static/js/a159160a02a1caf189f5008c0d150f36.png
Normal file
BIN
flatisfy/web/static/js/a159160a02a1caf189f5008c0d150f36.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
BIN
flatisfy/web/static/js/c6477ef24ce2054017fce1a2d8800385.png
Normal file
BIN
flatisfy/web/static/js/c6477ef24ce2054017fce1a2d8800385.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
BIN
flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png
Normal file
BIN
flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
@ -1,3 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
pylint --rcfile=.ci/pylintrc flatisfy
|
||||
npm run lint
|
||||
|
51
package.json
Normal file
51
package.json
Normal 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"
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
appdirs
|
||||
arrow
|
||||
bottle
|
||||
bottle-sqlalchemy
|
||||
canister
|
||||
enum34
|
||||
future
|
||||
request
|
||||
|
55
webpack.config.js
Normal file
55
webpack.config.js
Normal 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
33
wsgi.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user