Compare commits

...

30 Commits

Author SHA1 Message Date
799dfc18ed fix: update weather fetch timing to days
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
Docker / docker (push) Waiting to run
- Changed 'last_weather_fetch' and 'last_image_generation'
  from 10.hours.ago to 10.days.ago for various cities
- Affects seed data for cities in multiple countries, including:
  - Australia
  - Bangladesh
  - Brazil
  - Canada
  - China
  - Egypt
  - France
  - Germany
  - India
  - Japan
  - Mexico
  - Nigeria
  - Pakistan
  - Russia
  - Saudi Arabia
  - Singapore
  - South Korea
  - Thailand
  - Turkey
  - UK
  - USA
  - Vietnam

This change ensures that the timestamps reflect a more refined time range for when last weather data was fetched, possibly to enhance performance or consistency in application behavior.
2025-01-23 01:50:04 +08:00
a2c75ba3c2 feat: cache Docker layers and update dependencies
- Removed unnecessary queue configurations from Sidekiq configuration
- Added 'whenever' gem and its necessary dependencies for adding cron jobs
- Updated Docker workflow to cache Docker layers using actions/cache

This update improves the efficiency of CI/CD pipelines by caching Docker layers, which will help in reducing the time taken for the build process. Additionally, the changes in the Gemfile and the config/sidekiq.yml configuration will enable the project to run cron jobs for tasks like generating weather arts.
2025-01-23 01:37:47 +08:00
e9095ece6e feat: improve Sidekiq scheduling configuration
- Enable dynamic scheduling only if the schedule file exists
- Update the sidekiq.yml to include a new job for batch generation
- Define a cron schedule for the new job to run every 2 hours

This update allows Sidekiq to conditionally enable its scheduler and
introduces a new job that processes weather art batches every two
hours. The change enhances the job management and scheduling
dynamics in the application.
2025-01-23 01:07:39 +08:00
c20ff296eb fix: update Active Job queue adapter to Sidekiq
- Comment out previous queue adapter setting for Solid Queue
- Set Active Job queue adapter to Sidekiq for better performance

This change updates the Active Job queue adapter from Solid Queue to
Sidekiq. This adjustment is intended to improve job processing
performance and reliability in production environments. The previous
adapter setting will be retained as a comment for reference.
2025-01-23 00:51:56 +08:00
f74f34ce82 chore: update admin user creation for seeds
- Remove environment condition for admin user creation

This change simplifies the admin user creation in the seeds file by
removing the conditional check for the development environment.
As a result, the admin user will be created regardless of the
environment, which may need to be addressed to avoid unintended
consequences in production setups.
2025-01-23 00:40:42 +08:00
a6cea6b80d chore: setup admin user
- Create admin user with email 'admin@example.com' and password 'password' for development environment
This change allows for easier development and testing by providing a default admin user.
2025-01-23 00:40:19 +08:00
08c584b85b chore: rename job classes and update Sidekiq config
- Renamed `BatchGenerateWeatherArtsJob` to `BatchGenerateWeatherArtsWorker`
- Renamed `GenerateWeatherArtJob` to `GenerateWeatherArtWorker`
- Updated Sidekiq configuration to set Redis URL and logger level
- Modified `sidekiq.yml` to use the new worker class names and added queue configurations

These changes help in aligning the class names with their purpose as
workers in Sidekiq, while also ensuring better configuration for
Redis and logging.
2025-01-22 18:47:41 +08:00
78722caeb9 chore: update gem dependencies and formatting
- Standardized quotation marks in Gemfile and Ruby files
- Improved consistency for Sidekiq and Sidekiq Scheduler
- Removed unnecessary blank line in GenerateWeatherArtWorker class

These changes ensure a consistent coding style across the project, making
it easier to read and maintain. Adjustments to the Gemfile update the
formatting without altering the functionality.
2025-01-22 17:58:46 +08:00
2bcfea30ee feat: add background job processing with Sidekiq
- Implement BatchGenerateWeatherArtsWorker to handle batch
  processing of weather art generation.
- Create GenerateWeatherArtWorker for individual weather art
  generation tasks.
- Update Dockerfile to include redis-tools for Sidekiq support.
- Modify Gemfile to add sidekiq and sidekiq-scheduler gems.
- Configure Sidekiq in initializers and set up routes for
  Sidekiq dashboard.
- Include a sidekiq.yml configuration for scheduling jobs.
- Create compose.yaml for Docker services including web,
  database, Redis, and Sidekiq workers.

These changes introduce background processing capabilities
using Sidekiq, allowing for efficient generation of weather
art through scheduled and managed job queues, optimizing
performance and scalability.
2025-01-22 17:58:25 +08:00
607fc9e8b8 chore: update asset precompilation command
- Change the precompilation command to include RAILS_BUILD=1
- This modification allows for environment-specific builds without altering the existing secret handling

This update enhances the flexibility of the asset precompilation process during deployment while maintaining the required application security measures.
2025-01-22 17:29:43 +08:00
494ae40088 chore: update storage configuration for production
- Change active storage service to use `:build` or `:amazon`
- Added a new `build` service for local disk storage

This update allows for better flexibility in managing file
storage based on the environment. The configuration now checks
the `RAILS_BUILD` environment variable to decide on the
storage service, making it easier to handle local testing and
production deployments without manual adjustments.
2025-01-22 17:26:06 +08:00
3748ea5215 chore: update AWS region defaults in storage config
- Change default region for amazon_dev to 'wnam'
- Change default region for amazon to 'wnam'

This commit updates the storage configuration for AWS S3 by
setting the default region to 'wnam' for both development
and production environments. The change ensures that a valid
region is used even when the environment variable is not set.
This is a preparatory step for deployments needing a specific
region if the configuration is incomplete.
2025-01-22 17:17:05 +08:00
fd910fb469 fix: update job scheduling interval
- Change job execution frequency from once every hour to every two hours.
- Ensure the `BatchGenerateWeatherArtsJob` runs as intended without unnecessary frequency.

This change addresses performance concerns by reducing the load on the system caused by frequent job executions.
2025-01-22 17:08:46 +08:00
b5c40f2e13 fix: update Dockerfile and weather service configuration
- Added 'libpq-dev' to the packages installed in the Dockerfile
- Updated the base_uri in WeatherService to use 'dig' for safer access

These changes improve the Docker environment by ensuring that
necessary PostgreSQL development headers are available during
installation. The weather service now safely accesses the
URI from the credentials, reducing the risk of errors when
fetching nested configuration data.
2025-01-22 17:07:04 +08:00
2d81dd91e7 feat: activate cities in seed files
- Update active status of various cities from false to true
- Ensures that all seeded cities are now enabled and available for usage

This change activates previously inactive cities in the seed files, which
facilitates their accessibility in subsequent deployments and testing.
The modification was made across numerous city seed files to ensure a
consistent state where all cities are now marked as active, improving
the readiness of the application.
2025-01-22 17:00:27 +08:00
2ab495897d chore: update Dockerfile to install libpq5
- Add libpq5 to the list of installed packages for
  database connections.
- Remove libpq-dev from the list as it is no longer
  necessary for the build process.

These adjustments enhance the Docker image configuration,
ensuring the application can connect to PostgreSQL databases
without unnecessary build dependencies.
2025-01-22 16:57:54 +08:00
853a1d03ce refactor: clean up ai_service code formatting
- Adjust formatting for message parameters in the chat call
- Eliminate unnecessary blank lines in generate_prompt_request method
- Ensure consistent styles for arrays and block indentation

These changes improve code readability and maintain consistency in
formatting for better maintainability without altering functionality.
2025-01-22 16:50:21 +08:00
d728d7f50e feat: add batch weather art generation jobs
- Created BatchGenerateWeatherArtsJob to process eligible
  cities and generate weather art.
- Introduced GenerateWeatherArtJob for generating weather
  art and image attachment.
- Added AiService for obtaining prompts and generating
  images with OpenAI API.
- Implemented WeatherService to fetch current weather
  data from the QWeather API.
- Updated Gemfile with necessary gems (whenever,
  ruby-openai, httparty, down, aws-sdk-s3).

This commit introduces a system to create and store
weather art images for various cities based on current
weather conditions, leveraging external APIs for data
and image generation.
2025-01-22 16:50:00 +08:00
6e387d1a8c feat: enhance city weather card layout
- Improve image and main info section for better aesthetics
- Add gradient overlay for text readability
- Include temperature display with feel-like temperature
- Enhance city name and location styling
- Add detailed weather information metrics (Humidity, Wind, Visibility)
- Provide a backup UI for when no image is available

This update enhances the user interface of the city weather card
by improving the layout and adding more detailed weather information.
The gradient overlays and reorganized elements improve readability and
usability. Additionally, a fallback display is included for cases where
weather images are not attached, ensuring consistent design.
2025-01-22 14:51:00 +08:00
f7d295b41b feat: enhance cities filtering interface
- Introduce dropdown menus for region and country selection
- Adjust padding on title section for better layout
- Update filter navigation structure to improve UX

This commit refines the user interface for filtering cities by
allowing users to select regions and countries from dropdown
menus. The filtering options are now easier to navigate and
maintain a consistent aesthetic with adjusted padding for
better visual hierarchies.
2025-01-22 14:12:29 +08:00
da7fca139c feat: add city card component and improve index view
- Create a new partial for city card display with weather image
- Update index page to show featured weather art with gradient
- Refactor city listing layout and navigation for improved clarity

This commit introduces a new component for displaying city cards that
includes weather artwork when available. The index page has been
refactored for better visual presentation and usability. Users can
now better navigate through cities and see relevant weather data.
2025-01-22 14:10:43 +08:00
9cb1467301 feat: enhance weather arts and cities features
- Add slug column to weather_arts for friendly URLs.
- Update weather arts retrieval in the controller to use slug.
- Implement region and country filtering in cities index view.
- Optimize city queries with scopes for active status and region/country.
- Improve UI layout and design for the cities index page.

These changes allow better user experience by enabling cleaner URLs for weather arts and facilitating efficient filtering of cities based on selected regions and countries.
2025-01-22 14:04:58 +08:00
be7856935f
Merge pull request #1 from songtianlun/dependabot/bundler/selenium-webdriver-4.28.0
chore(deps-dev): bump selenium-webdriver from 4.27.0 to 4.28.0
2025-01-22 09:45:08 +08:00
e70763dbe0 chore: update workflow name for Docker build
- Change workflow name from 'Build and Publish Docker Image' to 'Docker'

This change simplifies the workflow name for better readability and
identification in the CI/CD pipeline.
2025-01-22 09:39:04 +08:00
f83600412b chore: update docker workflow to include tags
- Add support for tagging workflow triggers on pushes
- Include version tags following the pattern 'v*'

This change enhances the Docker workflow by allowing the CI/CD
pipeline to respond to version tags, which is beneficial for
versioned releases and better management of deployment processes.
2025-01-22 09:37:35 +08:00
93b24ff50c chore: update Dockerfile to include libpq-dev
- Add libpq-dev to the list of packages installed for the build
- Ensure that the environment has the necessary dependencies

This change updates the Dockerfile to include the libpq-dev package,
which is required for compiling certain gem dependencies. The inclusion
of this package improves the compatibility of the environment with
database-related gems during the build process, ensuring a smoother
setup for development and deployment.
2025-01-22 09:29:37 +08:00
c9aef3ddfe build: update Docker workflow for versioning
- Add checkout step to retrieve full git history
- Introduce a step to get the current version of the project
- Modify login step to use the actor's username instead of a constant
- Update the tags to use dynamic versioning in Docker image push

These changes improve the Docker workflow by ensuring that the
correct version tag is used when building and pushing Docker
images. The modified steps enhance traceability and allow
the workflow to handle versioning accurately based on current
git tags, addressing the need for specific version control in
the images produced.
2025-01-22 09:23:59 +08:00
48f3da8913 chore: update Docker workflow for tagging
- Remove unnecessary steps for version extraction
- Simplify tag assignment using environment variables

This update cleans up the Docker workflow by eliminating steps
that were not crucial for the tagging process. The tagging
is now done directly with the repository name, reducing redundancy
and improving clarity in the workflow configuration.
2025-01-22 09:19:28 +08:00
a988c49ae0 chore: update docker tags formatting
- Change Docker tags configuration to be multiline
- Improve clarity and maintainability of the workflow

This change enhances the readability of the tags in the
Docker workflow, facilitating easier updates and understanding
of the tagging process.
2025-01-22 09:07:52 +08:00
dependabot[bot]
e2e5e38a7d
chore(deps-dev): bump selenium-webdriver from 4.27.0 to 4.28.0
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.27.0 to 4.28.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.27.0...selenium-4.28.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-21 06:36:52 +00:00
53 changed files with 948 additions and 229 deletions

View File

@ -1,12 +1,13 @@
name: Build and Publish Docker Image name: Docker
on: on:
push: push:
branches: branches:
- main - main
tags:
- v*
env: env:
# Use docker.io for Docker Hub if empty
REGISTRY: docker.io REGISTRY: docker.io
IMAGE_NAME: ${{ github.event.repository.name }} IMAGE_NAME: ${{ github.event.repository.name }}
@ -14,40 +15,51 @@ jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get version
- name: Get Version
id: get_version id: get_version
run: | run: |
GIT_VERSION=$(git describe --dirty --always --long --abbrev=7 --tags) echo "VERSION=$(git describe --dirty --always --tags --abbrev=7)" >> $GITHUB_OUTPUT
VERSION="v${GIT_VERSION}"
IMAGE_PREFIX="${{ env.REGISTRY }}/${{ secrets.DOCKERHUB_USER }}/${{ env.IMAGE_NAME }}" - name: Login to ${{ env.REGISTRY }}
LATEST_TAG="${IMAGE_PREFIX}:latest"
VERSION_TAG="${IMAGE_PREFIX}:${VERSION}"
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "GIT_VERSION=${GIT_VERSION}" >> $GITHUB_ENV
echo "LATEST_TAG=${LATEST_TAG}" >> $GITHUB_OUTPUT
echo "VERSION_TAG=${VERSION_TAG}" >> $GITHUB_OUTPUT
-
name: Login to ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKERHUB_USER }} username: ${{github.actor}}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- with:
name: Build and push driver-opts: |
network=host
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ env.LATEST_TAG }},${{ env.VERSION_TAG }} tags: |
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:latest
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:${{ steps.get_version.outputs.VERSION }}
cache-from: |
type=local,src=/tmp/.buildx-cache
type=registry,ref=${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:latest
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

View File

@ -16,7 +16,7 @@ WORKDIR /rails
# Install base packages # Install base packages
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 libpq5 redis-tools && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment # Set production environment
@ -30,7 +30,7 @@ FROM base AS build
# Install packages needed to build gems and node modules # Install packages needed to build gems and node modules
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git node-gyp pkg-config python-is-python3 && \ apt-get install --no-install-recommends -y build-essential git node-gyp pkg-config python-is-python3 libpq-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install JavaScript dependencies # Install JavaScript dependencies
@ -59,7 +59,7 @@ COPY . .
RUN bundle exec bootsnap precompile app/ lib/ RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY # Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile RUN RAILS_BUILD=1 SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
RUN rm -rf node_modules RUN rm -rf node_modules

View File

@ -45,6 +45,14 @@ gem "devise", "~> 4.9"
gem "activeadmin", "~> 3.2" gem "activeadmin", "~> 3.2"
gem "friendly_id", "~> 5.5" gem "friendly_id", "~> 5.5"
# gem "whenever", "~> 1.0"
gem "ruby-openai", "~> 7.3"
gem "httparty", "~> 0.22.0"
gem "down", "~> 5.4"
gem "aws-sdk-s3", "~> 1.177"
gem "sidekiq", "~> 7.3"
gem "sidekiq-scheduler", "~> 5.0"
group :development, :test do group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

View File

@ -88,6 +88,22 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
ruby2_keywords (>= 0.0.2) ruby2_keywords (>= 0.0.2)
ast (2.4.2) ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.1035.0)
aws-sdk-core (3.215.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
@ -127,11 +143,22 @@ GEM
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
dotenv (3.1.7) dotenv (3.1.7)
down (5.4.2)
addressable (~> 2.8)
drb (2.2.1) drb (2.2.1)
ed25519 (1.3.0) ed25519 (1.3.0)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
event_stream_parser (1.0.0)
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
formtastic (5.0.0) formtastic (5.0.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
formtastic_i18n (0.7.0) formtastic_i18n (0.7.0)
@ -145,6 +172,10 @@ GEM
has_scope (0.8.2) has_scope (0.8.2)
actionpack (>= 5.2) actionpack (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.6) i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
inherited_resources (1.14.0) inherited_resources (1.14.0)
@ -159,6 +190,7 @@ GEM
jbuilder (2.13.0) jbuilder (2.13.0)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.6.2)
jquery-rails (4.6.0) jquery-rails (4.6.0)
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
@ -204,6 +236,11 @@ GEM
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.4) minitest (5.25.4)
msgpack (1.7.5) msgpack (1.7.5)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
multipart-post (2.4.1)
net-http (0.6.0)
uri
net-imap (0.5.5) net-imap (0.5.5)
date date
net-protocol net-protocol
@ -300,6 +337,8 @@ GEM
i18n i18n
rdoc (6.11.0) rdoc (6.11.0)
psych (>= 4.0.0) psych (>= 4.0.0)
redis-client (0.23.2)
connection_pool
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.0) reline (0.6.0)
io-console (~> 0.5) io-console (~> 0.5)
@ -335,16 +374,32 @@ GEM
rubocop-minitest rubocop-minitest
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (2.4.1)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.27.0) selenium-webdriver (4.28.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
sidekiq (7.3.8)
base64
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-scheduler (5.0.6)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
solid_cable (3.0.5) solid_cable (3.0.5)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
@ -384,6 +439,7 @@ GEM
thruster (0.1.10-arm64-darwin) thruster (0.1.10-arm64-darwin)
thruster (0.1.10-x86_64-darwin) thruster (0.1.10-x86_64-darwin)
thruster (0.1.10-x86_64-linux) thruster (0.1.10-x86_64-linux)
tilt (2.6.0)
timeout (0.4.3) timeout (0.4.3)
turbo-rails (2.0.11) turbo-rails (2.0.11)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
@ -425,13 +481,16 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
activeadmin (~> 3.2) activeadmin (~> 3.2)
aws-sdk-s3 (~> 1.177)
bootsnap bootsnap
brakeman brakeman
capybara capybara
cssbundling-rails cssbundling-rails
debug debug
devise (~> 4.9) devise (~> 4.9)
down (~> 5.4)
friendly_id (~> 5.5) friendly_id (~> 5.5)
httparty (~> 0.22.0)
jbuilder jbuilder
jsbundling-rails jsbundling-rails
kamal kamal
@ -440,7 +499,10 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.0.1) rails (~> 8.0.1)
rubocop-rails-omakase rubocop-rails-omakase
ruby-openai (~> 7.3)
selenium-webdriver selenium-webdriver
sidekiq (~> 7.3)
sidekiq-scheduler (~> 5.0)
solid_cable solid_cable
solid_cache solid_cache
solid_queue solid_queue

View File

@ -1,4 +1,9 @@
ActiveAdmin.register WeatherArt do ActiveAdmin.register WeatherArt do
controller do
def find_resource
scoped_collection.friendly.find(params[:id])
end
end
# See permitted parameters documentation: # See permitted parameters documentation:
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
# #
@ -16,9 +21,11 @@ ActiveAdmin.register WeatherArt do
permit_params :city_id, :weather_date, :description, :temperature, permit_params :city_id, :weather_date, :description, :temperature,
:feeling_temp, :humidity, :wind_scale, :wind_speed, :feeling_temp, :humidity, :wind_scale, :wind_speed,
:precipitation, :pressure, :visibility, :cloud, :precipitation, :pressure, :visibility, :cloud,
:prompt, :image :prompt, :image, :slug
remove_filter :image_attachment, :image_blob remove_filter :image_attachment, :image_blob
filter :city_id
filter :weather_data
index do index do
selectable_column selectable_column

View File

@ -1,6 +1,18 @@
class CitiesController < ApplicationController class CitiesController < ApplicationController
def index def index
@cities = City.all.order(:name) @cities = City.all.order(:name)
@regions = Region.includes(:countries).order(:name)
@cities = City.includes(:country, country: :region).active.order(:name)
if params[:region]
@current_region = Region.friendly.find(params[:region])
@cities = @cities.by_region(@current_region.id)
end
if params[:country]
@current_country = Country.friendly.find(params[:country])
@cities = @cities.by_country(@current_country.id)
end
end end
def show def show

View File

@ -1,6 +1,6 @@
class WeatherArtsController < ApplicationController class WeatherArtsController < ApplicationController
def show def show
@city = City.friendly.find(params[:city_id]) @city = City.friendly.find(params[:city_id])
@weather_art = @city.weather_arts.find(params[:id]) @weather_art = @city.weather_arts.friendly.find(params[:slug])
end end
end end

View File

@ -0,0 +1,33 @@
class BatchGenerateWeatherArtsJob < ApplicationJob
queue_as :default
def perform(*args)
start_time = Time.current
max_duration = 50.minutes
cities_to_process = get_eligible_cities
cities_to_process.each do |city|
break if Time.current - start_time > max_duration
GenerateWeatherArtJob.perform_now(city)
sleep 1.minute # 确保不超过API限制
end
end
private
def get_eligible_cities
City.active
.where(active: true)
.where("last_weather_fetch IS NULL OR last_weather_fetch < ?", Date.today)
# .select { |city| early_morning_in_timezone?(city.timezone) }
end
# def early_morning_in_timezone?(timezone)
# return false if timezone.blank?
# time = Time.current.in_time_zone(timezone)
# time.hour == 2
# end
end

View File

@ -0,0 +1,45 @@
class GenerateWeatherArtJob < ApplicationJob
queue_as :default
def perform(*args)
city = args[0]
return if city.last_weather_fetch&.today?
weather_service = WeatherService.new
ai_service = AiService.new
# 获取天气数据
weather_data = weather_service.get_weather(city.latitude, city.longitude)
return unless weather_data
# 生成提示词
prompt = ai_service.generate_prompt(city, weather_data)
return unless prompt
# 生成图像
image_url = ai_service.generate_image(prompt)
return unless image_url
# 创建天气艺术记录
weather_art = city.weather_arts.create!(
weather_date: Date.today,
**weather_data,
prompt: prompt
)
# 下载并附加图像
tempfile = Down.download(image_url)
weather_art.image.attach(
io: tempfile,
filename: "#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png"
)
# 更新城市状态
city.update!(
last_weather_fetch: Time.current,
last_image_generation: Time.current
)
rescue => e
Rails.logger.error "Error generating weather art for #{city.name}: #{e.message}"
end
end

View File

@ -1,6 +1,6 @@
class City < ApplicationRecord class City < ApplicationRecord
extend FriendlyId extend FriendlyId
friendly_id :name, use: :slugged friendly_id :slug_candidates, use: :slugged
belongs_to :country belongs_to :country
has_many :weather_arts, dependent: :destroy has_many :weather_arts, dependent: :destroy
@ -11,10 +11,21 @@ class City < ApplicationRecord
delegate :region, to: :country delegate :region, to: :country
scope :by_region, ->(region_id) { joins(:country).where(countries: { region_id: region_id }) }
scope :by_country, ->(country_id) { where(country_id: country_id) }
scope :active, -> { where(active: true) }
def to_s def to_s
name name
end end
def slug_candidates
[
:name,
[ :country, :name ]
]
end
def localized_name def localized_name
I18n.t("cities.#{name.parameterize.underscore}") I18n.t("cities.#{name.parameterize.underscore}")
end end

View File

@ -1,11 +1,21 @@
class WeatherArt < ApplicationRecord class WeatherArt < ApplicationRecord
belongs_to :city extend FriendlyId
friendly_id :weather_date, use: :slugged
belongs_to :city
has_one_attached :image has_one_attached :image
validates :weather_date, presence: true validates :weather_date, presence: true
validates :city_id, presence: true validates :city_id, presence: true
def should_generate_new_friendly_id?
weather_date_changed? || city_id_changed? || super
end
def to_s
"#{city.name} - #{weather_date.strftime('%Y-%m-%d')}"
end
def self.ransackable_associations(auth_object = nil) def self.ransackable_associations(auth_object = nil)
[ "city", "image_attachment", "image_blob" ] [ "city", "image_attachment", "image_blob" ]
end end

View File

@ -0,0 +1,64 @@
class AiService
def initialize
@client = OpenAI::Client.new(
access_token: Rails.application.credentials.openai.token,
uri_base: Rails.application.credentials.openai.uri,
request_timeout: 240
)
end
def generate_prompt(city, weather_data)
response = @client.chat(
parameters: {
model: "gpt-4",
messages: [ {
role: "system",
content: "You are a professional artist creating prompts for DALL-E 3. Create realistic, artistic weather scenes featuring iconic landmarks."
}, {
role: "user",
content: generate_prompt_request(city, weather_data)
} ],
temperature: 0.7,
max_tokens: 300
}
)
response.dig("choices", 0, "message", "content")
end
def generate_image(prompt)
response = @client.images.generate(
parameters: {
model: "dall-e-3",
prompt: prompt,
size: "1792x1024",
quality: "standard",
n: 1
}
)
response.dig("data", 0, "url")
end
private
def generate_prompt_request(city, weather_data)
<<~PROMPT
Create a DALL-E 3 prompt for a weather scene in #{city.name}, #{city.country.name}.
Weather conditions:
- Temperature: #{weather_data[:temperature]}°C
- Weather: #{weather_data[:description]}
- Cloud cover: #{weather_data[:cloud]}%
- Time: Early morning
Requirements:
- Feature iconic landmarks or architecture from #{city.name}
- Realistic style
- Weather conditions should be clearly visible
- Atmospheric and artistic composition
Generate a detailed, creative prompt that will produce a beautiful and realistic image.
PROMPT
end
end

View File

@ -0,0 +1,37 @@
# app/services/weather_service.rb
class WeatherService
include HTTParty
base_uri Rails.application.credentials.dig(:qweather, :uri)
def initialize
@api_key = Rails.application.credentials.qweather.token
end
def get_weather(latitude, longitude)
Rails.logger.debug "Get Weather for #{latitude},#{longitude}"
response = self.class.get(
"/weather/now",
headers: {
"X-QW-Api-Key" => "#{@api_key}"
},
query: {
location: "#{longitude},#{latitude}"
})
return nil unless response.success?
data = response["now"]
{
temperature: data["temp"].to_f,
feeling_temp: data["feelsLike"].to_f,
humidity: data["humidity"].to_f,
wind_scale: "#{data['windScale']}",
wind_speed: data["windSpeed"].to_f,
precipitation: data["precip"].to_f,
pressure: data["pressure"].to_f,
visibility: data["vis"].to_f,
cloud: data["cloud"].to_f,
description: data["text"]
}
end
end

View File

@ -0,0 +1,126 @@
<!-- app/views/cities/_city.html.erb -->
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group overflow-hidden">
<% if city.latest_weather_art&.image&.attached? %>
<!-- 图片和主要信息区域 -->
<div class="relative">
<!-- 图片 -->
<figure class="aspect-[16/9] overflow-hidden">
<%= image_tag city.latest_weather_art.image,
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
</figure>
<!-- 渐变遮罩 - 使用多层渐变提供更好的文字可读性 -->
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"></div>
<div class="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-transparent"></div>
<!-- 温度显示 - 放在右上角 -->
<div class="absolute top-4 right-4 bg-black/30 backdrop-blur-sm rounded-xl px-3 py-2 text-white">
<div class="text-2xl font-bold leading-none">
<%= city.latest_weather_art.temperature %>°C
</div>
<div class="text-xs text-white/80">
Feels <%= city.latest_weather_art.feeling_temp %>°C
</div>
</div>
<!-- 城市名称和位置 - 放在底部 -->
<div class="absolute bottom-0 left-0 right-0 p-6">
<h3 class="text-2xl font-display text-white font-bold mb-2 drop-shadow-lg">
<%= city.name %>
</h3>
<div class="flex items-center gap-2 text-white/90 text-sm drop-shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<%= city.country.name %>, <%= city.region.name %>
</div>
</div>
</div>
<!-- 底部信息区域 -->
<div class="card-body bg-base-100">
<!-- 天气信息和更新时间 -->
<div class="flex items-center justify-between text-sm text-base-content/70 mb-3">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary/70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<%= city.latest_weather_art.description %>
</div>
<div class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<%= city.latest_weather_art.weather_date.strftime("%b %d, %Y") %>
</div>
</div>
<!-- 天气详情 -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="stat bg-base-200/50 rounded-lg p-2">
<div class="stat-title text-xs">Humidity</div>
<div class="stat-value text-lg"><%= city.latest_weather_art.humidity %>%</div>
</div>
<div class="stat bg-base-200/50 rounded-lg p-2">
<div class="stat-title text-xs">Wind</div>
<div class="stat-value text-lg"><%= city.latest_weather_art.wind_scale %></div>
</div>
<div class="stat bg-base-200/50 rounded-lg p-2">
<div class="stat-title text-xs">Visibility</div>
<div class="stat-value text-lg"><%= city.latest_weather_art.visibility %>km</div>
</div>
</div>
<!-- 按钮 -->
<div class="card-actions justify-end">
<%= link_to city_path(city),
class: "btn btn-primary btn-sm gap-2" do %>
View Details
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<% end %>
</div>
</div>
<% else %>
<!-- 无图片时的备用显示 -->
<div class="card-body">
<h3 class="card-title font-display text-2xl mb-3"><%= city.name %></h3>
<div class="flex items-center gap-2 text-base-content/70 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<%= city.country.name %>, <%= city.region.name %>
</div>
<div class="bg-base-200 rounded-lg p-4 mb-4">
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary/70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<span>Lat: <%= city.latitude %></span>
</div>
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary/70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<span>Long: <%= city.longitude %></span>
</div>
</div>
</div>
<div class="card-actions justify-end">
<%= link_to city_path(city),
class: "btn btn-primary btn-sm gap-2" do %>
View Details
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<% end %>
</div>
</div>
<% end %>
</div>

View File

@ -1,35 +1,126 @@
<div class="container mx-auto px-4 py-16"> <!-- app/views/cities/index.html.erb -->
<div class="text-center mb-16 space-y-4"> <div class="min-h-screen">
<h1 class="text-4xl md:text-5xl font-display font-bold">Explore Cities</h1> <!-- 页面标题和背景 -->
<p class="text-xl text-base-content/70 max-w-2xl mx-auto"> <% featured_art = WeatherArt.includes(:city).joins(:image_attachment).order(created_at: :desc).first %>
Discover AI-generated weather art from cities around the world <div class="relative bg-base-100">
</p> <!-- 背景图像和渐变 -->
</div> <% if featured_art&.image&.attached? %>
<div class="absolute inset-0 h-[60vh] overflow-hidden">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <%= image_tag featured_art.image,
<% @cities.each do |city| %> class: "w-full h-full object-cover object-center" %>
<% latest_art = city.weather_arts.last %> <div class="absolute inset-0 bg-gradient-to-b from-base-100/40 via-base-100/80 to-base-100"></div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group">
<% if latest_art&.image&.attached? %>
<figure class="relative aspect-[16/9] overflow-hidden">
<%= image_tag latest_art.image,
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
</figure>
<% end %>
<div class="card-body relative">
<h2 class="card-title font-display text-2xl"><%= city.localized_name %></h2>
<div class="text-base-content/70">
<p>Lat: <%= city.latitude %></p>
<p>Long: <%= city.longitude %></p>
</div>
<div class="card-actions justify-end mt-4">
<%= link_to "View Weather Art", city_path(city),
class: "btn btn-primary" %>
</div>
</div>
</div> </div>
<% end %> <% end %>
<!-- 标题内容 -->
<div class="relative pt-24 pb-32">
<div class="container mx-auto px-4">
<div class="max-w-3xl mx-auto text-center space-y-6">
<h1 class="text-5xl md:text-6xl font-display font-bold leading-tight">
Explore Cities
</h1>
<p class="text-xl md:text-2xl text-base-content/70 font-light max-w-2xl mx-auto">
Discover AI-generated weather art from cities around the world
</p>
<!-- 特色图片信息 -->
<% if featured_art %>
<div class="inline-block mt-6 px-4 py-2 bg-base-100/80 backdrop-blur-sm rounded-full text-sm">
Latest from
<span class="font-semibold"><%= featured_art.city.name %></span>,
<%= featured_art.city.country.name %>
<span class="mx-2">•</span>
<%= featured_art.weather_date.strftime("%B %d, %Y") %>
</div>
<% end %>
</div>
</div>
</div>
</div>
<!-- 筛选导航 - 使用下拉菜单 -->
<div class="sticky top-16 z-20 bg-base-100/95 backdrop-blur-sm border-b border-base-200">
<div class="container mx-auto px-4">
<div class="py-3 flex items-center justify-between gap-4">
<!-- 左侧筛选器 -->
<div class="flex items-center gap-4">
<!-- 区域选择下拉框 -->
<div class="dropdown">
<button class="btn btn-ghost gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<%= @current_region&.name || 'All Regions' %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
<li>
<%= link_to cities_path,
class: "#{@current_region ? '' : 'active'}" do %>
All Regions
<% end %>
</li>
<div class="divider my-1"></div>
<% @regions.each do |region| %>
<li>
<%= link_to region.name,
cities_path(region: region.slug),
class: "#{@current_region == region ? 'active' : ''}" %>
</li>
<% end %>
</ul>
</div>
<!-- 国家选择下拉框 (如果选择了区域) -->
<% if @current_region %>
<div class="dropdown">
<button class="btn btn-ghost gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
<%= @current_country&.name || "All Countries" %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
<li>
<%= link_to "All in #{@current_region.name}",
cities_path(region: @current_region.slug),
class: "#{@current_country ? '' : 'active'}" %>
</li>
<div class="divider my-1"></div>
<% @current_region.countries.order(:name).each do |country| %>
<li>
<%= link_to country.name,
cities_path(region: @current_region.slug, country: country.slug),
class: "#{@current_country == country ? 'active' : ''}" %>
</li>
<% end %>
</ul>
</div>
<% end %>
</div>
<!-- 右侧结果统计 -->
<div class="text-sm text-base-content/70">
<%= @cities.count %> <%= 'city'.pluralize(@cities.count) %>
<% if @current_country %>
in <%= @current_country.name %>
<% elsif @current_region %>
in <%= @current_region.name %>
<% end %>
</div>
</div>
</div>
</div>
<!-- 城市网格 -->
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<%= render partial: 'city', collection: @cities %>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,33 @@
class BatchGenerateWeatherArtsWorker
include Sidekiq::Worker
def perform(*args)
start_time = Time.current
max_duration = 50.minutes
cities_to_process = get_eligible_cities
cities_to_process.each do |city|
break if Time.current - start_time > max_duration
GenerateWeatherArtJob.perform_now(city)
sleep 1.minute # 确保不超过API限制
end
end
private
def get_eligible_cities
City.active
.where(active: true)
.where("last_weather_fetch IS NULL OR last_weather_fetch < ?", Date.today)
# .select { |city| early_morning_in_timezone?(city.timezone) }
end
# def early_morning_in_timezone?(timezone)
# return false if timezone.blank?
# time = Time.current.in_time_zone(timezone)
# time.hour == 2
# end
end

View File

@ -0,0 +1,43 @@
class GenerateWeatherArtWorker
def perform(*args)
city = args[0]
return if city.last_weather_fetch&.today?
weather_service = WeatherService.new
ai_service = AiService.new
# 获取天气数据
weather_data = weather_service.get_weather(city.latitude, city.longitude)
return unless weather_data
# 生成提示词
prompt = ai_service.generate_prompt(city, weather_data)
return unless prompt
# 生成图像
image_url = ai_service.generate_image(prompt)
return unless image_url
# 创建天气艺术记录
weather_art = city.weather_arts.create!(
weather_date: Date.today,
**weather_data,
prompt: prompt
)
# 下载并附加图像
tempfile = Down.download(image_url)
weather_art.image.attach(
io: tempfile,
filename: "#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png"
)
# 更新城市状态
city.update!(
last_weather_fetch: Time.current,
last_image_generation: Time.current
)
rescue => e
Rails.logger.error "Error generating weather art for #{city.name}: #{e.message}"
end
end

41
compose.yaml Normal file
View File

@ -0,0 +1,41 @@
version: '3.8'
services:
web:
image: songtianlun/today_ai_weather:latest
ports:
- "2222:3000"
environment:
- RAILS_ENV=production
- DATABASE_URL=postgresql://postgres:xxx@db:5432/db
- RAILS_MASTER_KEY=xxx
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
sidekiq:
image: songtianlun/today_ai_weather:latest
command: bundle exec sidekiq
environment:
- RAILS_ENV=production
- DATABASE_URL=postgresql://postgres:xxx@db:5432/db
- RAILS_MASTER_KEY=xxx
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
db:
image: postgres:16
volumes:
- ./data/pg:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=xxx
- POSTGRES_DB=db
redis:
image: redis:7-alpine
volumes:
- ./data/redis:/data
command: redis-server --appendonly yes

View File

@ -1 +1 @@
GwoFK6l/nbuNuzqsSphWsWWAkF3rF3LqQJXbBlMhZqleAE/wXGYzxgansBULOsF+/0hMDNT+AMQdLKPd+J+Oo55LNVHd9Dk6iJ4wtRJm5Ya4czrPTqv4dezNwRFm6soC085fUONQ8hxShenYWSNtUD+X9HmjG3xDYOMk8XHJRdz+TzUqzk8+sTee1xFTP3ZFTF1HYHE+3csz22rA0KBekDbSL+HK/rlKvNK3UJK/LM71V1BmaPSBC50X+D7TkGZGatMRLPwM3C3m/6PPvB838zeH7fm2maPx2/Y4zPZhTarUg3/U+R0wGfGbSCdMMBy/A+04C7KxadTn4Ivk3zXI3zloEbgY+s1pq6RZNKuLfQFUDzpDtdnC7tjYRnMBo6C3c8iAgI0gnnYHY0zHPws+umUlS/kdiWBn6jvsNc6zz6bSyBY8xIuL4zm/Aa4wQJc5u1wLTQW00nXKJvYo7bvZsWhusATQlPm+VK+U0rdh6uAyaeatwc951KB3--N7qeSFBaRTq/dtNy--L8fWHRQMpTXu5+6E1HsNaQ== 0Upt112zhMfb3aADOgxSWUCTTUNgtTrpoivR9+KJFKcazTjqRauvQ9W+uicYrjJjv497gHMFhusWZysDl1/uwHHaoHvyp7auIjRHiOR1YKfRLgbMSYMgSOKlP4HbiZ66hjhT+4T50JjmwwLcPFtgPaZG3lasTAXejc9ejiSn+OsIGvJje5MXnPREId5pvC/SNW4uCYMe6OEsfhTlsj8hES7odHJzMsGQGGuVkcg1EimogOE9oWK6FS0bj45R+7HCiIYlbcmDkRNraGDMGJI+oaxx2mi+34ENoxqfbYEv4PhsWPYcOROK8FyrpXwvRG/a1J8LFxTLkVloKsBIInLojvd9u8zwpcTOtsaHOxsQV7gLzUt2GbUiT4EZUdzHpIDRboJEuVxm6ioBctcJ744xnkn5rxOoJH+JWRhxf+CVLYojcYMdkcyGEOHv0nXtpWtu/xsJT3fTqJBLuyGWNQUfTDsw3qQDUOrsgSNvXvhEu1iWptoByp4d0adaCDS0+TVt+BFg2xPzSnIU3IAXgSnhyLTVd87jxBMHtcngIj4HAj+nDIs65KT4dk4Y+ns3eWE345Z0CzUz1J6M8ikva0cQiBB6wxMl1yghysCcSfKX4z/KbuJv6StQJIqw4HkDZ9youXEbnmiXTxuEaC0Yg3SE0dFf5k9Rj5govLMdCf1Msj04RQHt0b08s3UdTgegriru+j7dC6yTaDbHkrNorREME3SENzdksI1D5k08D4Z+TYpO/+yyZE5qKELEse9TgJiFZ6g1H2sFs9zCxBDif67ovFSqrRaNgP7qBY8uEfFNVP6Y6eSnfccJlFBGlwflhwwA1osit+5rKlvhqQs37qsmFZRuj9BdrAJbdr/0ogEbLyeTEw3Zq+0m0/Vf4j5ueCv2eDpk/gSQdl869v+5anLTqMUWM4KV1wS1OrPkMC5XkZHLUo1/1+Dl6FKfZhWydRso1R9WL5B0Jk7UEWMPX0qoxxuixR1jwWzYmlehq0iNw7hQPdgwCbDtRxEMPr8MkUnG5Fd0YZUfnhHJ+EWVCZHKhUJclRSB9LnDKVPJr/oFuOHLmJg6Vll4WHCnIK9nCHdr7ed6O89vA5Z8DRlE/6yAXFlPKrCldWYbrL3BAgzQK8MxD5fzgzRkGiwVEtQwhbtbdDHUJ9rdAEmLJZj1UJXLIhX9jFspB9stHqyRxrcef/Y+h9dBvuFdvSIFEXA0TWHintx9YDgsJb591d6rjJn+JYEmZs9UONEEuiijPf9waqTb0oIrUQwnlUOX69bBipiU5ZQ2yQvepuRY3ooK3IGHvngxZPT0F8Mfh5Zcx9GGJd17LZb9DJOs7uOlrpXydToq/3Qm1q6TmgjbFYpC6iyrhDyE5XLL/XwVavh7m2QYqVbUXk2tGk2C0UR6CTE6tsV1+kDs1nylIEgAA5Nr43HQtRAXPmo0k8Txy5wMMIbLE1keqjl7XdGPnLsX9CJi6ue6KZxhigwcdakvW6Y=--H101ebDKrzdM39ll--a5R5klrhU6QgghGPjjWfAQ==

View File

@ -29,7 +29,8 @@ Rails.application.configure do
config.cache_store = :memory_store config.cache_store = :memory_store
# Store uploaded files on the local file system (see config/storage.yml for options). # Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local # config.active_storage.service = :local
config.active_storage.service = :amazon_dev
# Don't care if the mailer can't send. # Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false config.action_mailer.raise_delivery_errors = false

View File

@ -22,7 +22,7 @@ Rails.application.configure do
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"
# Store uploaded files on the local file system (see config/storage.yml for options). # Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local config.active_storage.service = ENV["RAILS_BUILD"] ? :build : :amazon
# Assume all access to the app is happening through a SSL-terminating reverse proxy. # Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true config.assume_ssl = true
@ -50,7 +50,8 @@ Rails.application.configure do
config.cache_store = :solid_cache_store config.cache_store = :solid_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job. # Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue # config.active_job.queue_adapter = :solid_queue
config.active_job.queue_adapter = :sidekiq
config.solid_queue.connects_to = { database: { writing: :queue } } config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors. # Ignore bad email addresses and do not raise email delivery errors.

View File

@ -0,0 +1,20 @@
require "sidekiq"
require "sidekiq-scheduler"
Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") }
config.logger.level = Logger::INFO
config.on(:startup) do
schedule_file = "config/sidekiq.yml"
if File.exist?(schedule_file)
Sidekiq::Scheduler.enabled = true
Sidekiq::Scheduler.dynamic = true
Sidekiq.schedule = YAML.load_file(schedule_file)
Sidekiq::Scheduler.reload_schedule!
end
end
end
Sidekiq.configure_client do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") }
end

View File

@ -1,8 +1,10 @@
require "sidekiq/web"
Rails.application.routes.draw do Rails.application.routes.draw do
root "home#index" root "home#index"
resources :cities, only: [ :index, :show ] do resources :cities, only: [ :index, :show ] do
resources :weather_arts, only: [ :show ] resources :weather_arts, path: "weather", only: [ :show ], param: :slug
end end
# namespace :admin do # namespace :admin do
@ -17,6 +19,11 @@ Rails.application.routes.draw do
get "home/index" get "home/index"
devise_for :admin_users, ActiveAdmin::Devise.config devise_for :admin_users, ActiveAdmin::Devise.config
ActiveAdmin.routes(self) ActiveAdmin.routes(self)
# mount Sidekiq::Web => '/sidekiq'
authenticate :admin_user do
mount Sidekiq::Web => "/sidekiq"
end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.

23
config/schedule.rb Normal file
View File

@ -0,0 +1,23 @@
# Use this file to easily define all of your cron jobs.
#
# It's helpful, but not entirely necessary to understand cron before proceeding.
# http://en.wikipedia.org/wiki/Cron
# Example:
#
# set :output, "/path/to/my/cron_log.log"
#
# every 2.hours do
# command "/usr/bin/some_great_command"
# runner "MyModel.some_method"
# rake "some:great:rake:task"
# end
#
# every 4.days do
# runner "AnotherModel.prune_old_records"
# end
# every 2.hour do
# runner "BatchGenerateWeatherArtsJob.perform_later"
# end
# Learn more: http://github.com/javan/whenever

5
config/sidekiq.yml Normal file
View File

@ -0,0 +1,5 @@
batch_generate_weather:
cron: '0 */2 * * *'
class: BatchGenerateWeatherArtsWorker
description: "Generate weather arts every 2 hours"
enabled: true

View File

@ -6,6 +6,10 @@ local:
service: Disk service: Disk
root: <%= Rails.root.join("storage") %> root: <%= Rails.root.join("storage") %>
build:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon: # amazon:
# service: S3 # service: S3
@ -13,6 +17,21 @@ local:
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1 # region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %> # bucket: your_own_bucket-<%= Rails.env %>
amazon_dev:
service: S3
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :access_key_id)) %>
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key)) %>
region: <%= ENV.fetch("AWS_REGION", "wnam") %>
bucket: <%= ENV.fetch("AWS_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket)) %>
endpoint: <%= ENV.fetch("AWS_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint)) %>
amazon:
service: S3
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws, :access_key_id)) %>
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws, :secret_access_key)) %>
region: <%= ENV.fetch("AWS_REGION", "wnam") %>
bucket: <%= ENV.fetch("AWS_BUCKET", Rails.application.credentials.dig(:aws, :bucket)) %>
endpoint: <%= ENV.fetch("AWS_ENDPOINT", Rails.application.credentials.dig(:aws, :endpoint)) %>
# Remember not to checkin your GCS keyfile to a repository # Remember not to checkin your GCS keyfile to a repository
# google: # google:

View File

@ -0,0 +1,6 @@
class AddSlugToWeatherArts < ActiveRecord::Migration[8.0]
def change
add_column :weather_arts, :slug, :string
add_index :weather_arts, :slug, unique: true
end
end

17
db/schema.rb generated
View File

@ -10,17 +10,14 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_21_020653) do ActiveRecord::Schema[8.0].define(version: 2025_01_22_053220) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
create_table "active_admin_comments", force: :cascade do |t| create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace" t.string "namespace"
t.text "body" t.text "body"
t.string "resource_type" t.string "resource_type"
t.bigint "resource_id" t.integer "resource_id"
t.string "author_type" t.string "author_type"
t.bigint "author_id" t.integer "author_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["author_type", "author_id"], name: "index_active_admin_comments_on_author" t.index ["author_type", "author_id"], name: "index_active_admin_comments_on_author"
@ -80,7 +77,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_020653) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "slug" t.string "slug"
t.bigint "country_id", null: false t.integer "country_id", null: false
t.index ["country_id"], name: "index_cities_on_country_id" t.index ["country_id"], name: "index_cities_on_country_id"
t.index ["slug"], name: "index_cities_on_slug", unique: true t.index ["slug"], name: "index_cities_on_slug", unique: true
end end
@ -89,7 +86,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_020653) do
t.string "name" t.string "name"
t.string "code" t.string "code"
t.string "slug" t.string "slug"
t.bigint "region_id", null: false t.integer "region_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["code"], name: "index_countries_on_code", unique: true t.index ["code"], name: "index_countries_on_code", unique: true
@ -119,7 +116,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_020653) do
end end
create_table "weather_arts", force: :cascade do |t| create_table "weather_arts", force: :cascade do |t|
t.bigint "city_id", null: false t.integer "city_id", null: false
t.date "weather_date" t.date "weather_date"
t.string "description" t.string "description"
t.decimal "temperature" t.decimal "temperature"
@ -134,7 +131,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_020653) do
t.text "prompt" t.text "prompt"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "slug"
t.index ["city_id"], name: "index_weather_arts_on_city_id" t.index ["city_id"], name: "index_weather_arts_on_city_id"
t.index ["slug"], name: "index_weather_arts_on_slug", unique: true
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"

View File

@ -7,7 +7,8 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name) # MovieGenre.find_or_create_by!(name: genre_name)
# end # end
AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password') if Rails.env.development? # AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password') if Rails.env.development?
AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password')
WeatherArt.delete_all WeatherArt.delete_all
City.delete_all City.delete_all
@ -139,19 +140,7 @@ Country.create!([
Dir[Rails.root.join('db/seeds/cities/*.rb')].sort.each do |file| Dir[Rails.root.join('db/seeds/cities/*.rb')].sort.each do |file|
require file require file
end end
guangzhou = City.find_by name: 'Guangzhou'
china = Country.find_by(code: 'CN')
guangzhou = City.create!(
name: 'Guangzhou',
latitude: 23.1291,
longitude: 113.2644,
priority: 50,
country: china,
timezone: 'Asia/Shanghai',
active: true,
last_weather_fetch: 10.hours.ago,
last_image_generation: 10.hours.ago
)
guangzhou_weather_art = WeatherArt.create!( guangzhou_weather_art = WeatherArt.create!(
city: guangzhou, city: guangzhou,

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Australia/Sydney', timezone: 'Australia/Sydney',
active: true, active: true,
priority: 80, priority: 80,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Melbourne', name: 'Melbourne',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Australia/Melbourne', timezone: 'Australia/Melbourne',
active: true, active: true,
priority: 75, priority: 75,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Asia/Dhaka', timezone: 'Asia/Dhaka',
active: true, active: true,
priority: 85, priority: 85,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'America/Sao_Paulo', timezone: 'America/Sao_Paulo',
active: true, active: true,
priority: 80, priority: 80,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -7,6 +7,6 @@ City.create!(
country: canada, country: canada,
timezone: 'America/Toronto', timezone: 'America/Toronto',
active: true, active: true,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
) )

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Beijing', name: 'Beijing',
@ -20,8 +20,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Shenzhen', name: 'Shenzhen',
@ -31,8 +31,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Guangzhou', name: 'Guangzhou',
@ -42,8 +42,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Chengdu', name: 'Chengdu',
@ -53,8 +53,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Tianjin', name: 'Tianjin',
@ -64,8 +64,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Wuhan', name: 'Wuhan',
@ -75,8 +75,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Dongguan', name: 'Dongguan',
@ -86,8 +86,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Chongqing', name: 'Chongqing',
@ -97,8 +97,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: "Xi'an", name: "Xi'an",
@ -108,8 +108,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Hangzhou', name: 'Hangzhou',
@ -119,8 +119,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Foshan', name: 'Foshan',
@ -130,8 +130,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Nanjing', name: 'Nanjing',
@ -141,8 +141,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Hong Kong', name: 'Hong Kong',
@ -152,8 +152,8 @@ City.create!([
timezone: 'Asia/Hong_Kong', timezone: 'Asia/Hong_Kong',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Shenyang', name: 'Shenyang',
@ -163,8 +163,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Zhengzhou', name: 'Zhengzhou',
@ -174,8 +174,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Qingdao', name: 'Qingdao',
@ -185,8 +185,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Suzhou', name: 'Suzhou',
@ -196,8 +196,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Changsha', name: 'Changsha',
@ -207,8 +207,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Jinan', name: 'Jinan',
@ -218,8 +218,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Kunming', name: 'Kunming',
@ -229,8 +229,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Harbin', name: 'Harbin',
@ -240,8 +240,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Shijiazhuang', name: 'Shijiazhuang',
@ -251,8 +251,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Hefei', name: 'Hefei',
@ -262,8 +262,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Dalian', name: 'Dalian',
@ -273,8 +273,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Xiamen', name: 'Xiamen',
@ -284,8 +284,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Nanning', name: 'Nanning',
@ -295,8 +295,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Changchun', name: 'Changchun',
@ -306,8 +306,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Taiyuan', name: 'Taiyuan',
@ -317,8 +317,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'New Taipei City', name: 'New Taipei City',
@ -328,8 +328,8 @@ City.create!([
timezone: 'Asia/Taipei', timezone: 'Asia/Taipei',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Guiyang', name: 'Guiyang',
@ -339,8 +339,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Wuxi', name: 'Wuxi',
@ -350,8 +350,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Shantou', name: 'Shantou',
@ -361,8 +361,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Ürümqi', name: 'Ürümqi',
@ -372,8 +372,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Zhongshan', name: 'Zhongshan',
@ -383,8 +383,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Ningbo', name: 'Ningbo',
@ -394,8 +394,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Fuzhou', name: 'Fuzhou',
@ -405,8 +405,8 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Nanchang', name: 'Nanchang',
@ -416,7 +416,7 @@ City.create!([
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Africa/Cairo', timezone: 'Africa/Cairo',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Europe/Paris', timezone: 'Europe/Paris',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Berlin', name: 'Berlin',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Asia/Kolkata', timezone: 'Asia/Kolkata',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Bengaluru', name: 'Bengaluru',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Asia/Kolkata', timezone: 'Asia/Kolkata',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Asia/Tokyo', timezone: 'Asia/Tokyo',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Yokohama', name: 'Yokohama',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Asia/Tokyo', timezone: 'Asia/Tokyo',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'America/Mexico_City', timezone: 'America/Mexico_City',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Africa/Lagos', timezone: 'Africa/Lagos',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Asia/Karachi', timezone: 'Asia/Karachi',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Europe/Moscow', timezone: 'Europe/Moscow',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Sankt Petersburg', name: 'Sankt Petersburg',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Europe/Moscow', timezone: 'Europe/Moscow',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Asia/Riyadh', timezone: 'Asia/Riyadh',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Asia/Singapore', timezone: 'Asia/Singapore',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Asia/Seoul', timezone: 'Asia/Seoul',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Asia/Bangkok', timezone: 'Asia/Bangkok',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Europe/Istanbul', timezone: 'Europe/Istanbul',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Ankara', name: 'Ankara',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Europe/Istanbul', timezone: 'Europe/Istanbul',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,7 +9,7 @@ City.create!([
timezone: 'Europe/London', timezone: 'Europe/London',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'America/Los_Angeles', timezone: 'America/Los_Angeles',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Chicago', name: 'Chicago',
@ -20,8 +20,8 @@ City.create!([
timezone: 'America/Chicago', timezone: 'America/Chicago',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'New York City', name: 'New York City',
@ -31,8 +31,8 @@ City.create!([
timezone: 'America/New_York', timezone: 'America/New_York',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Los Angeles', name: 'Los Angeles',
@ -42,7 +42,7 @@ City.create!([
timezone: 'America/Los_Angeles', timezone: 'America/Los_Angeles',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -9,8 +9,8 @@ City.create!([
timezone: 'Asia/Ho_Chi_Minh', timezone: 'Asia/Ho_Chi_Minh',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
}, },
{ {
name: 'Hanoi', name: 'Hanoi',
@ -20,7 +20,7 @@ City.create!([
timezone: 'Asia/Ho_Chi_Minh', timezone: 'Asia/Ho_Chi_Minh',
active: true, active: true,
priority: 100, priority: 100,
last_weather_fetch: 10.hours.ago, last_weather_fetch: 10.days.ago,
last_image_generation: 10.hours.ago last_image_generation: 10.days.ago
} }
]) ])

View File

@ -0,0 +1,7 @@
require "test_helper"
class BatchGenerateWeatherArtsJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
require "test_helper"
class GenerateWeatherArtJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end