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
|
build
|
||||||
*.json
|
|
||||||
*.pyc
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.db
|
*.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.
|
explicitly mentionned otherwise.
|
||||||
|
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See the `CONTRIBUTING.md` file for more infos.
|
||||||
|
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
* [Weboob](http://weboob.org/)
|
* [Weboob](http://weboob.org/)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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,7 +208,6 @@ 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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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:
|
||||||
|
housing_posts.extend(
|
||||||
|
weboob_proxy.query(query, config["max_entries"])
|
||||||
)
|
)
|
||||||
query_flats_list = json.loads(flatboob_output)
|
LOGGER.info("Fetched %d flats.", len(housing_posts))
|
||||||
LOGGER.info("Fetched %d flats.", len(query_flats_list))
|
|
||||||
flats_list.extend(query_flats_list)
|
flats_list = [json.loads(flat) for flat in housing_posts]
|
||||||
LOGGER.info("Fetched a total of %d flats.", len(flats_list))
|
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
|
with WeboobProxy(config) as weboob_proxy:
|
||||||
LOGGER.info("Loading additional details for flat %s.", flat_id)
|
LOGGER.info("Loading additional details for flat %s.", flat_id)
|
||||||
flatboob_output = subprocess.check_output(
|
weboob_output = weboob_proxy.info(flat_id)
|
||||||
["../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)
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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,21 +201,29 @@ 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:
|
|
||||||
|
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
|
# flat1 has empty matching field, just keep the flat2 field
|
||||||
merged_flat[k] = value2
|
merged_flat[k] = value2
|
||||||
elif value2 is None:
|
elif not value2:
|
||||||
# flat2 field is empty, just keep the flat1 field
|
# flat2 field is empty, just keep the flat1 field
|
||||||
merged_flat[k] = value1
|
merged_flat[k] = value1
|
||||||
else:
|
else:
|
||||||
# Any other case, we should merge
|
# Any other case, we should keep the value of the more recent flat
|
||||||
# TODO: Do the merge
|
# dict (the one most at right in arguments)
|
||||||
merged_flat[k] = value1
|
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:])
|
return merge_dicts(merged_flat, *args[2:])
|
||||||
|
|
||||||
|
|
||||||
|
@ -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"))
|
||||||
|
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):
|
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):
|
||||||
|
"""
|
||||||
|
Wrap the callback in a call to get_session.
|
||||||
|
"""
|
||||||
with self.get_session() as session:
|
with self.get_session() as session:
|
||||||
kwargs = {}
|
# Get a db session and pass it to the callback
|
||||||
kwargs[self.KEYWORD] = session
|
kwargs[self.KEYWORD] = session
|
||||||
return functools.partial(callback, **kwargs)
|
return callback(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
Plugin = DatabasePlugin
|
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
|
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
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
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
|
#!/bin/sh
|
||||||
|
|
||||||
pylint --rcfile=.ci/pylintrc flatisfy
|
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
|
appdirs
|
||||||
|
arrow
|
||||||
bottle
|
bottle
|
||||||
bottle-sqlalchemy
|
bottle-sqlalchemy
|
||||||
|
canister
|
||||||
enum34
|
enum34
|
||||||
future
|
future
|
||||||
request
|
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